diff --git a/myproject/inventory/migrations/0010_writeoff_document.py b/myproject/inventory/migrations/0010_writeoff_document.py new file mode 100644 index 0000000..54601f2 --- /dev/null +++ b/myproject/inventory/migrations/0010_writeoff_document.py @@ -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'), + ), + ] diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py index 594c0c0..343f739 100644 --- a/myproject/inventory/models.py +++ b/myproject/inventory/models.py @@ -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'))