Добавлена модель документа списания товаров (WriteOffDocument)
- Создана модель WriteOffDocument для коллективного списания с поддержкой статусов (черновик/проведен/отменен) - Добавлена модель WriteOffDocumentItem для позиций документа - Расширена модель Reservation связью с WriteOffDocumentItem для резервирования товара в черновике - Добавлен тип счетчика 'writeoff' в DocumentCounter для автонумерации - Реализована логика резервирования товара в черновике документа (уменьшает quantity_free) - При проведении документа создаются WriteOff записи по методу FIFO
This commit is contained in:
91
myproject/inventory/migrations/0010_writeoff_document.py
Normal file
91
myproject/inventory/migrations/0010_writeoff_document.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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