Добавлена модель документа списания товаров (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

@@ -0,0 +1,91 @@
# Generated by Django 5.0.10 on 2025-12-10 19:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0009_fix_showcase_items_status'),
('products', '0010_alter_product_cost_price'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='documentcounter',
name='counter_type',
field=models.CharField(choices=[('transfer', 'Перемещение товара'), ('writeoff', 'Списание товара')], max_length=20, unique=True, verbose_name='Тип счетчика'),
),
migrations.CreateModel(
name='WriteOffDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('document_number', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Номер документа')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('confirmed', 'Проведён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('date', models.DateField(help_text='Дата, к которой относится списание', verbose_name='Дата документа')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('confirmed_at', models.DateTimeField(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='Обновлён')),
('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Провёл')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_writeoff_documents', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_documents', to='inventory.warehouse', verbose_name='Склад')),
],
options={
'verbose_name': 'Документ списания',
'verbose_name_plural': 'Документы списания',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='WriteOffDocumentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
('reason', models.CharField(choices=[('damage', 'Повреждение'), ('spoilage', 'Порча'), ('shortage', 'Недостача'), ('inventory', 'Инвентаризационная недостача'), ('other', 'Другое')], default='damage', max_length=20, verbose_name='Причина списания')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='inventory.writeoffdocument', verbose_name='Документ')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='writeoff_document_items', to='products.product', verbose_name='Товар')),
('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='writeoff_document_item_reverse', to='inventory.reservation', verbose_name='Резерв')),
],
options={
'verbose_name': 'Позиция документа списания',
'verbose_name_plural': 'Позиции документа списания',
'ordering': ['id'],
},
),
migrations.AddField(
model_name='reservation',
name='writeoff_document_item',
field=models.ForeignKey(blank=True, help_text='Резерв для документа списания (черновик)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='inventory.writeoffdocumentitem', verbose_name='Позиция документа списания'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['document_number'], name='inventory_w_documen_a9ae00_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['warehouse', 'status'], name='inventory_w_warehou_69fbf6_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['date'], name='inventory_w_date_a853cb_idx'),
),
migrations.AddIndex(
model_name='writeoffdocument',
index=models.Index(fields=['-created_at'], name='inventory_w_created_02c298_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['document'], name='inventory_w_documen_d77c5e_idx'),
),
migrations.AddIndex(
model_name='writeoffdocumentitem',
index=models.Index(fields=['product'], name='inventory_w_product_6e32fc_idx'),
),
]

View File

@@ -461,6 +461,17 @@ class Reservation(models.Model):
help_text="Для какого физического экземпляра создан резерв" 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: class Meta:
verbose_name = "Резервирование" verbose_name = "Резервирование"
verbose_name_plural = "Резервирования" verbose_name_plural = "Резервирования"
@@ -729,6 +740,7 @@ class DocumentCounter(models.Model):
""" """
COUNTER_TYPE_CHOICES = [ COUNTER_TYPE_CHOICES = [
('transfer', 'Перемещение товара'), ('transfer', 'Перемещение товара'),
('writeoff', 'Списание товара'),
] ]
counter_type = models.CharField( counter_type = models.CharField(
@@ -870,3 +882,201 @@ class TransferItem(models.Model):
def __str__(self): def __str__(self):
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.id})" 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'))