refactor: подготовка к стандартизации Transfer моделей
Текущее состояние перед рефакторингом Transfer → TransferDocument. Все изменения с последнего коммита по улучшению системы поступлений. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from django.db.models import Sum
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from inventory.models import (
|
from inventory.models import (
|
||||||
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
Warehouse, StockBatch, Sale, WriteOff, Transfer,
|
||||||
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
||||||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
||||||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Warehouse, Incoming, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
Warehouse, Sale, WriteOff, Transfer, Reservation, Inventory, InventoryLine, StockBatch,
|
||||||
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
TransferBatch, TransferItem, Showcase, WriteOffDocument, WriteOffDocumentItem, Stock,
|
||||||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput
|
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-26 14:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0003_alter_documentcounter_counter_type_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='incomingbatch',
|
||||||
|
name='warehouse',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Incoming',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='IncomingBatch',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -100,84 +100,9 @@ class StockBatch(models.Model):
|
|||||||
return f"{self.product.name} на {self.warehouse.name} - Остаток: {self.quantity} шт @ {self.cost_price} за ед."
|
return f"{self.product.name} на {self.warehouse.name} - Остаток: {self.quantity} шт @ {self.cost_price} за ед."
|
||||||
|
|
||||||
|
|
||||||
class IncomingBatch(models.Model):
|
# Модели IncomingBatch и Incoming удалены - заменены на IncomingDocument/IncomingDocumentItem
|
||||||
"""
|
# Теперь используется упрощенная архитектура:
|
||||||
Партия поступления товара (один номер документа = одна партия).
|
# IncomingDocument → IncomingDocumentItem → StockBatch (напрямую при проведении)
|
||||||
Содержит один номер документа и может включать несколько товаров.
|
|
||||||
"""
|
|
||||||
RECEIPT_TYPE_CHOICES = [
|
|
||||||
('supplier', 'Поступление от поставщика'),
|
|
||||||
('inventory', 'Оприходование при инвентаризации'),
|
|
||||||
('adjustment', 'Оприходование без инвентаризации'),
|
|
||||||
]
|
|
||||||
|
|
||||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
|
||||||
related_name='incoming_batches', verbose_name="Склад")
|
|
||||||
document_number = models.CharField(max_length=100, unique=True, db_index=True,
|
|
||||||
verbose_name="Номер документа")
|
|
||||||
receipt_type = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=RECEIPT_TYPE_CHOICES,
|
|
||||||
default='supplier',
|
|
||||||
db_index=True,
|
|
||||||
verbose_name="Тип поступления"
|
|
||||||
)
|
|
||||||
supplier_name = models.CharField(max_length=200, blank=True, null=True,
|
|
||||||
verbose_name="Наименование поставщика")
|
|
||||||
notes = models.TextField(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="Дата обновления")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Партия поступления"
|
|
||||||
verbose_name_plural = "Партии поступлений"
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['document_number']),
|
|
||||||
models.Index(fields=['warehouse']),
|
|
||||||
models.Index(fields=['receipt_type']),
|
|
||||||
models.Index(fields=['-created_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
total_items = self.items.count()
|
|
||||||
total_qty = self.items.aggregate(models.Sum('quantity'))['quantity__sum'] or 0
|
|
||||||
return f"Партия {self.document_number}: {total_items} товаров, {total_qty} шт"
|
|
||||||
|
|
||||||
|
|
||||||
class Incoming(models.Model):
|
|
||||||
"""
|
|
||||||
Товар в партии поступления. Много товаров = одна партия (IncomingBatch).
|
|
||||||
"""
|
|
||||||
batch = models.ForeignKey(IncomingBatch, on_delete=models.CASCADE,
|
|
||||||
related_name='items', verbose_name="Партия")
|
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
|
||||||
related_name='incomings', verbose_name="Товар")
|
|
||||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
|
||||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Закупочная цена")
|
|
||||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
|
||||||
stock_batch = models.ForeignKey(StockBatch, on_delete=models.SET_NULL, null=True, blank=True,
|
|
||||||
related_name='incomings', verbose_name="Складская партия")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Товар в поступлении"
|
|
||||||
verbose_name_plural = "Товары в поступлениях"
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['batch']),
|
|
||||||
models.Index(fields=['product']),
|
|
||||||
models.Index(fields=['-created_at']),
|
|
||||||
]
|
|
||||||
unique_together = [['batch', 'product']] # Один товар максимум один раз в партии
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.product.name}: {self.quantity} шт (партия {self.batch.document_number})"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def can_edit(self):
|
|
||||||
"""Можно ли редактировать приход"""
|
|
||||||
return self.stock_batch is None
|
|
||||||
|
|
||||||
|
|
||||||
class Sale(models.Model):
|
class Sale(models.Model):
|
||||||
@@ -1207,8 +1132,8 @@ class IncomingDocument(models.Model):
|
|||||||
Сценарий использования:
|
Сценарий использования:
|
||||||
1. Создается черновик (draft)
|
1. Создается черновик (draft)
|
||||||
2. В течение дня добавляются товары (IncomingDocumentItem)
|
2. В течение дня добавляются товары (IncomingDocumentItem)
|
||||||
3. В конце смены документ проводится (confirmed) → создаются IncomingBatch и Incoming
|
3. В конце смены документ проводится (confirmed) → создается StockBatch напрямую
|
||||||
4. Сигнал автоматически создает StockBatch и обновляет Stock
|
4. Stock автоматически обновляется
|
||||||
"""
|
"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('draft', 'Черновик'),
|
('draft', 'Черновик'),
|
||||||
@@ -1216,6 +1141,12 @@ class IncomingDocument(models.Model):
|
|||||||
('cancelled', 'Отменён'),
|
('cancelled', 'Отменён'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RECEIPT_TYPE_CHOICES = [
|
||||||
|
('supplier', 'Поступление от поставщика'),
|
||||||
|
('inventory', 'Оприходование при инвентаризации'),
|
||||||
|
('adjustment', 'Оприходование без инвентаризации'),
|
||||||
|
]
|
||||||
|
|
||||||
document_number = models.CharField(
|
document_number = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -1245,7 +1176,7 @@ class IncomingDocument(models.Model):
|
|||||||
|
|
||||||
receipt_type = models.CharField(
|
receipt_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=IncomingBatch.RECEIPT_TYPE_CHOICES,
|
choices=RECEIPT_TYPE_CHOICES,
|
||||||
default='supplier',
|
default='supplier',
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Тип поступления"
|
verbose_name="Тип поступления"
|
||||||
@@ -1355,9 +1286,8 @@ class IncomingDocumentItem(models.Model):
|
|||||||
- Резервирование НЕ создается (товар еще не поступил)
|
- Резервирование НЕ создается (товар еще не поступил)
|
||||||
|
|
||||||
При проведении документа:
|
При проведении документа:
|
||||||
1. Создается IncomingBatch с номером документа
|
1. Для каждой позиции напрямую создается StockBatch
|
||||||
2. Создается Incoming запись для каждого товара
|
2. Stock автоматически обновляется
|
||||||
3. Сигнал create_stock_batch_on_incoming автоматически создает StockBatch
|
|
||||||
"""
|
"""
|
||||||
document = models.ForeignKey(
|
document = models.ForeignKey(
|
||||||
IncomingDocument,
|
IncomingDocument,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from django.utils import timezone
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from inventory.models import (
|
from inventory.models import (
|
||||||
IncomingDocument, IncomingDocumentItem, IncomingBatch, Incoming
|
IncomingDocument, IncomingDocumentItem, StockBatch, Stock
|
||||||
)
|
)
|
||||||
from inventory.utils.document_generator import generate_incoming_document_number
|
from inventory.utils.document_generator import generate_incoming_document_number
|
||||||
|
|
||||||
@@ -165,10 +165,9 @@ class IncomingDocumentService:
|
|||||||
|
|
||||||
Процесс:
|
Процесс:
|
||||||
1. Проверяем что документ - черновик и имеет позиции
|
1. Проверяем что документ - черновик и имеет позиции
|
||||||
2. Создаем IncomingBatch с номером документа
|
2. Для каждой позиции создаем StockBatch напрямую
|
||||||
3. Для каждой позиции создаем Incoming запись
|
3. Обновляем Stock
|
||||||
4. Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
|
4. Меняем статус документа на 'confirmed'
|
||||||
5. Меняем статус документа на 'confirmed'
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: IncomingDocument
|
document: IncomingDocument
|
||||||
@@ -188,30 +187,30 @@ class IncomingDocumentService:
|
|||||||
if not document.items.exists():
|
if not document.items.exists():
|
||||||
raise ValidationError("Нельзя провести пустой документ")
|
raise ValidationError("Нельзя провести пустой документ")
|
||||||
|
|
||||||
# Создаем IncomingBatch
|
# Создаем StockBatch напрямую для каждого товара
|
||||||
incoming_batch = IncomingBatch.objects.create(
|
batches_created = []
|
||||||
warehouse=document.warehouse,
|
|
||||||
document_number=document.document_number,
|
|
||||||
receipt_type=document.receipt_type,
|
|
||||||
supplier_name=document.supplier_name if document.receipt_type == 'supplier' else '',
|
|
||||||
notes=document.notes
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создаем Incoming записи для каждого товара
|
|
||||||
incomings_created = []
|
|
||||||
total_cost = Decimal('0')
|
total_cost = Decimal('0')
|
||||||
|
|
||||||
for item in document.items.select_related('product'):
|
for item in document.items.select_related('product'):
|
||||||
incoming = Incoming.objects.create(
|
# Создаем партию товара на складе
|
||||||
batch=incoming_batch,
|
stock_batch = StockBatch.objects.create(
|
||||||
product=item.product,
|
product=item.product,
|
||||||
|
warehouse=document.warehouse,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
cost_price=item.cost_price,
|
cost_price=item.cost_price,
|
||||||
notes=item.notes
|
is_active=True
|
||||||
)
|
)
|
||||||
incomings_created.append(incoming)
|
batches_created.append(stock_batch)
|
||||||
total_cost += item.total_cost
|
total_cost += item.total_cost
|
||||||
|
|
||||||
|
# Обновляем или создаем запись в Stock
|
||||||
|
stock, _ = Stock.objects.get_or_create(
|
||||||
|
product=item.product,
|
||||||
|
warehouse=document.warehouse
|
||||||
|
)
|
||||||
|
# Пересчитываем остаток из всех активных партий
|
||||||
|
stock.refresh_from_batches()
|
||||||
|
|
||||||
# Обновляем статус документа
|
# Обновляем статус документа
|
||||||
document.status = 'confirmed'
|
document.status = 'confirmed'
|
||||||
document.confirmed_by = confirmed_by
|
document.confirmed_by = confirmed_by
|
||||||
@@ -220,8 +219,7 @@ class IncomingDocumentService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'document': document,
|
'document': document,
|
||||||
'incoming_batch': incoming_batch,
|
'batches_created': len(batches_created),
|
||||||
'incomings_created': len(incomings_created),
|
|
||||||
'total_quantity': document.total_quantity,
|
'total_quantity': document.total_quantity,
|
||||||
'total_cost': total_cost
|
'total_cost': total_cost
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from decimal import Decimal
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from orders.models import Order, OrderItem
|
from orders.models import Order, OrderItem
|
||||||
from inventory.models import Reservation, Warehouse, Incoming, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem, Transformation, TransformationInput, TransformationOutput
|
from inventory.models import Reservation, Warehouse, StockBatch, Sale, SaleBatchAllocation, Inventory, WriteOff, Stock, WriteOffDocumentItem, Transformation, TransformationInput, TransformationOutput
|
||||||
from inventory.services import SaleProcessor
|
from inventory.services import SaleProcessor
|
||||||
from inventory.services.batch_manager import StockBatchManager
|
from inventory.services.batch_manager import StockBatchManager
|
||||||
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
||||||
@@ -1046,131 +1046,7 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Incoming)
|
# Сигналы для Incoming удалены - теперь StockBatch создается напрямую в IncomingDocumentService
|
||||||
def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
|
|
||||||
"""
|
|
||||||
Сигнал: При создании товара в приходе (Incoming) автоматически создается StockBatch и обновляется Stock.
|
|
||||||
|
|
||||||
Архитектура:
|
|
||||||
- IncomingBatch: одна партия поступления (IN-0000-0001) содержит несколько товаров
|
|
||||||
- Incoming: один товар в партии поступления
|
|
||||||
- StockBatch: одна партия товара на складе (создается для каждого товара в приходе)
|
|
||||||
Для FIFO: каждый товар имеет свою partия, чтобы можно было списывать отдельно
|
|
||||||
|
|
||||||
Процесс:
|
|
||||||
1. Проверяем, новый ли товар в приходе
|
|
||||||
2. Если stock_batch еще не создан - создаем StockBatch для этого товара
|
|
||||||
3. Связываем Incoming с созданной StockBatch
|
|
||||||
4. Обновляем остатки на складе (Stock)
|
|
||||||
"""
|
|
||||||
if not created:
|
|
||||||
return # Только для новых приходов
|
|
||||||
|
|
||||||
# Если stock_batch уже установлен - не создаем новый
|
|
||||||
if instance.stock_batch:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Получаем данные из партии поступления
|
|
||||||
incoming_batch = instance.batch
|
|
||||||
warehouse = incoming_batch.warehouse
|
|
||||||
|
|
||||||
# Создаем новую партию товара на складе
|
|
||||||
# Каждый товар в партии поступления → отдельная StockBatch
|
|
||||||
stock_batch = StockBatch.objects.create(
|
|
||||||
product=instance.product,
|
|
||||||
warehouse=warehouse,
|
|
||||||
quantity=instance.quantity,
|
|
||||||
cost_price=instance.cost_price,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Связываем Incoming с созданной StockBatch
|
|
||||||
instance.stock_batch = stock_batch
|
|
||||||
instance.save(update_fields=['stock_batch'])
|
|
||||||
|
|
||||||
# Обновляем или создаем запись в Stock
|
|
||||||
stock, created_stock = Stock.objects.get_or_create(
|
|
||||||
product=instance.product,
|
|
||||||
warehouse=warehouse
|
|
||||||
)
|
|
||||||
# Пересчитываем остаток из всех активных партий
|
|
||||||
# refresh_from_batches() уже вызывает save(), поэтому не вызываем ещё раз
|
|
||||||
stock.refresh_from_batches()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Incoming)
|
|
||||||
def update_stock_batch_on_incoming_edit(sender, instance, created, **kwargs):
|
|
||||||
"""
|
|
||||||
Сигнал: При редактировании товара в приходе (Incoming) автоматически
|
|
||||||
обновляется связанная партия товара на складе (StockBatch).
|
|
||||||
|
|
||||||
Это обеспечивает синхронизацию данных между Incoming и StockBatch.
|
|
||||||
|
|
||||||
Архитектура:
|
|
||||||
- Если Incoming редактируется - обновляем StockBatch с новыми значениями
|
|
||||||
- Обновление StockBatch автоматически пересчитывает себестоимость товара (Product.cost_price)
|
|
||||||
через сигнал update_product_cost_on_batch_change()
|
|
||||||
|
|
||||||
Процесс:
|
|
||||||
1. Проверяем, это редактирование (created=False), а не создание
|
|
||||||
2. Получаем связанный StockBatch
|
|
||||||
3. Проверяем, изменились ли quantity или cost_price
|
|
||||||
4. Если да - обновляем StockBatch
|
|
||||||
5. Сохраняем StockBatch (запускает цепь пересчета себестоимости)
|
|
||||||
6. Обновляем остатки на складе (Stock)
|
|
||||||
"""
|
|
||||||
if created:
|
|
||||||
return # Только для редактирования (не для создания)
|
|
||||||
|
|
||||||
# Получаем связанный StockBatch
|
|
||||||
if not instance.stock_batch:
|
|
||||||
return # Если нет связи со StockBatch - нечего обновлять
|
|
||||||
|
|
||||||
stock_batch = instance.stock_batch
|
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Проверяем, отличаются ли значения в StockBatch от Incoming
|
|
||||||
# Это говорит нам о том, что произошло редактирование
|
|
||||||
needs_update = (
|
|
||||||
stock_batch.quantity != instance.quantity or
|
|
||||||
stock_batch.cost_price != instance.cost_price
|
|
||||||
)
|
|
||||||
|
|
||||||
if not needs_update:
|
|
||||||
return # Никаких изменений
|
|
||||||
|
|
||||||
# Обновляем StockBatch с новыми значениями из Incoming
|
|
||||||
stock_batch.quantity = instance.quantity
|
|
||||||
stock_batch.cost_price = instance.cost_price
|
|
||||||
stock_batch.save()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"✓ StockBatch #{stock_batch.id} обновлён при редактировании Incoming: "
|
|
||||||
f"quantity={instance.quantity}, cost_price={instance.cost_price} "
|
|
||||||
f"(товар: {instance.product.sku})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем Stock (остатки на складе)
|
|
||||||
warehouse = stock_batch.warehouse
|
|
||||||
stock, _ = Stock.objects.get_or_create(
|
|
||||||
product=instance.product,
|
|
||||||
warehouse=warehouse
|
|
||||||
)
|
|
||||||
stock.refresh_from_batches()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"✓ Stock обновлён для товара {instance.product.sku} "
|
|
||||||
f"на складе {warehouse.name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Ошибка при обновлении StockBatch при редактировании Incoming #{instance.id}: {e}",
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Sale)
|
@receiver(post_save, sender=Sale)
|
||||||
|
|||||||
@@ -534,6 +534,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ДОКУМЕНТЫ ПОСТУПЛЕНИЯ (IncomingDocument) -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3>📥 Документы поступления IncomingDocument ({{ incoming_documents.count }})</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Номер</th>
|
||||||
|
<th>Склад</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Поставщик</th>
|
||||||
|
<th>Создал</th>
|
||||||
|
<th>Провёл</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for doc in incoming_documents %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ doc.id }}</td>
|
||||||
|
<td><strong>{{ doc.document_number }}</strong></td>
|
||||||
|
<td>{{ doc.warehouse.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if doc.status == 'draft' %}
|
||||||
|
<span class="badge bg-warning text-dark">Черновик</span>
|
||||||
|
{% elif doc.status == 'confirmed' %}
|
||||||
|
<span class="badge bg-success">Проведён</span>
|
||||||
|
{% elif doc.status == 'cancelled' %}
|
||||||
|
<span class="badge bg-danger">Отменён</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ doc.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-info">{{ doc.get_receipt_type_display }}</span></td>
|
||||||
|
<td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td>
|
||||||
|
<td>{{ doc.supplier_name|default:"-" }}</td>
|
||||||
|
<td class="text-muted-small">{{ doc.created_by.username|default:"-" }}</td>
|
||||||
|
<td class="text-muted-small">
|
||||||
|
{% if doc.confirmed_by %}
|
||||||
|
{{ doc.confirmed_by.username }} ({{ doc.confirmed_at|date:"d.m H:i" }})
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="9" class="text-center text-muted">Нет документов поступления</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- СТРОКИ ДОКУМЕНТОВ ПОСТУПЛЕНИЯ (IncomingDocumentItem) -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3>📋 Строки документов поступления IncomingDocumentItem ({{ incoming_document_items.count }})</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Документ</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Склад</th>
|
||||||
|
<th>Кол-во</th>
|
||||||
|
<th>Себестоимость</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Примечания</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in incoming_document_items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.id }}</td>
|
||||||
|
<td><strong>{{ item.document.document_number }}</strong></td>
|
||||||
|
<td><strong>{{ item.product.name }}</strong> ({{ item.product.sku }})</td>
|
||||||
|
<td>{{ item.document.warehouse.name }}</td>
|
||||||
|
<td><span class="badge bg-success">{{ item.quantity }}</span></td>
|
||||||
|
<td>{{ item.cost_price }} ₽</td>
|
||||||
|
<td><strong>{{ item.total_cost }} ₽</strong></td>
|
||||||
|
<td class="text-muted-small">{{ item.notes|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted">Нет строк документов поступления</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-secondary py-2 mt-3" style="font-size: 10px;">
|
<div class="alert alert-secondary py-2 mt-3" style="font-size: 10px;">
|
||||||
<strong>Примечание:</strong> Показаны последние 100 записей для каждой таблицы.
|
<strong>Примечание:</strong> Показаны последние 100 записей для каждой таблицы.
|
||||||
Используйте фильтры для уточнения результатов.
|
Используйте фильтры для уточнения результатов.
|
||||||
|
|||||||
@@ -239,16 +239,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Партии поступлений -->
|
<!-- Документы поступлений -->
|
||||||
<div class="col-md-6 col-lg-4">
|
<div class="col-md-6 col-lg-4">
|
||||||
<a href="{% url 'inventory:incoming-batch-list' %}" class="card shadow-sm h-100 text-decoration-none">
|
<a href="{% url 'inventory:incoming-list' %}" class="card shadow-sm h-100 text-decoration-none">
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="rounded-circle bg-secondary bg-opacity-10 p-3 me-3">
|
<div class="rounded-circle bg-secondary bg-opacity-10 p-3 me-3">
|
||||||
<i class="bi bi-box-arrow-in-down text-secondary" style="font-size: 1.5rem;"></i>
|
<i class="bi bi-box-arrow-in-down text-secondary" style="font-size: 1.5rem;"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h6 class="mb-0 text-dark">Партии поступлений</h6>
|
<h6 class="mb-0 text-dark">Документы поступлений</h6>
|
||||||
<small class="text-muted">История поступлений</small>
|
<small class="text-muted">История поступлений</small>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-chevron-right text-muted"></i>
|
<i class="bi bi-chevron-right text-muted"></i>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
# Warehouse
|
# Warehouse
|
||||||
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
||||||
# IncomingBatch
|
|
||||||
IncomingBatchListView, IncomingBatchDetailView,
|
|
||||||
# Sale
|
# Sale
|
||||||
SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView,
|
SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView,
|
||||||
# Inventory
|
# Inventory
|
||||||
@@ -64,9 +62,8 @@ urlpatterns = [
|
|||||||
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
|
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
|
||||||
path('warehouses/<int:pk>/set-default/', SetDefaultWarehouseView.as_view(), name='warehouse-set-default'),
|
path('warehouses/<int:pk>/set-default/', SetDefaultWarehouseView.as_view(), name='warehouse-set-default'),
|
||||||
|
|
||||||
# ==================== INCOMING BATCH ====================
|
# ==================== INCOMING BATCH (УДАЛЕНО) ====================
|
||||||
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
|
# IncomingBatch и Incoming удалены. Используйте IncomingDocument вместо них.
|
||||||
path('incoming-batches/<int:pk>/', IncomingBatchDetailView.as_view(), name='incoming-batch-detail'),
|
|
||||||
|
|
||||||
# ==================== SALE ====================
|
# ==================== SALE ====================
|
||||||
path('sales/', SaleListView.as_view(), name='sale-list'),
|
path('sales/', SaleListView.as_view(), name='sale-list'),
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def _initialize_incoming_counter_if_needed():
|
|||||||
Вызывается только если счетчик равен 0 (не инициализирован).
|
Вызывается только если счетчик равен 0 (не инициализирован).
|
||||||
Thread-safe через select_for_update.
|
Thread-safe через select_for_update.
|
||||||
"""
|
"""
|
||||||
from inventory.models import IncomingBatch, IncomingDocument
|
from inventory.models import IncomingDocument
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
# Быстрая проверка без блокировки - если счетчик существует и > 0, выходим
|
# Быстрая проверка без блокировки - если счетчик существует и > 0, выходим
|
||||||
@@ -94,20 +94,10 @@ def _initialize_incoming_counter_if_needed():
|
|||||||
if counter and counter.current_value > 0:
|
if counter and counter.current_value > 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Собираем все номера документов
|
# Собираем все номера документов из IncomingDocument
|
||||||
all_numbers = []
|
all_numbers = list(IncomingDocument.objects.filter(
|
||||||
|
|
||||||
# Номера из IncomingBatch
|
|
||||||
batch_numbers = IncomingBatch.objects.filter(
|
|
||||||
document_number__startswith='IN-'
|
document_number__startswith='IN-'
|
||||||
).values_list('document_number', flat=True)
|
).values_list('document_number', flat=True))
|
||||||
all_numbers.extend(batch_numbers)
|
|
||||||
|
|
||||||
# Номера из IncomingDocument
|
|
||||||
doc_numbers = IncomingDocument.objects.filter(
|
|
||||||
document_number__startswith='IN-'
|
|
||||||
).values_list('document_number', flat=True)
|
|
||||||
all_numbers.extend(doc_numbers)
|
|
||||||
|
|
||||||
if all_numbers:
|
if all_numbers:
|
||||||
# Извлекаем максимальный номер из всех форматов
|
# Извлекаем максимальный номер из всех форматов
|
||||||
@@ -135,7 +125,7 @@ def generate_incoming_document_number():
|
|||||||
Thread-safe через DocumentCounter.
|
Thread-safe через DocumentCounter.
|
||||||
|
|
||||||
При первом использовании автоматически инициализирует DocumentCounter
|
При первом использовании автоматически инициализирует DocumentCounter
|
||||||
максимальным номером из существующих документов (IncomingBatch и IncomingDocument).
|
максимальным номером из существующих документов IncomingDocument.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Сгенерированный номер документа (например, IN-000001)
|
str: Сгенерированный номер документа (например, IN-000001)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from django.shortcuts import render
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
||||||
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
from .batch import StockBatchListView, StockBatchDetailView
|
||||||
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
||||||
from .inventory_ops import (
|
from .inventory_ops import (
|
||||||
InventoryListView, InventoryCreateView, InventoryDetailView,
|
InventoryListView, InventoryCreateView, InventoryDetailView,
|
||||||
@@ -57,8 +57,6 @@ __all__ = [
|
|||||||
'inventory_home',
|
'inventory_home',
|
||||||
# Warehouse
|
# Warehouse
|
||||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
||||||
# IncomingBatch
|
|
||||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
|
||||||
# Sale
|
# Sale
|
||||||
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
|
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
|
||||||
# Inventory
|
# Inventory
|
||||||
|
|||||||
@@ -1,47 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Batch views - READ ONLY
|
Batch views - READ ONLY
|
||||||
- IncomingBatch (Партии поступлений)
|
|
||||||
- StockBatch (Партии товара на складе)
|
- StockBatch (Партии товара на складе)
|
||||||
|
|
||||||
|
ПРИМЕЧАНИЕ: IncomingBatch и Incoming удалены. Используйте IncomingDocument вместо них.
|
||||||
"""
|
"""
|
||||||
from django.views.generic import ListView, DetailView
|
from django.views.generic import ListView, DetailView
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from ..models import IncomingBatch, Incoming, StockBatch, SaleBatchAllocation, WriteOff
|
from ..models import StockBatch, SaleBatchAllocation, WriteOff
|
||||||
|
|
||||||
|
|
||||||
class IncomingBatchListView(LoginRequiredMixin, ListView):
|
|
||||||
"""Список всех партий поступлений товара"""
|
|
||||||
model = IncomingBatch
|
|
||||||
template_name = 'inventory/incoming_batch/batch_list.html'
|
|
||||||
context_object_name = 'batches'
|
|
||||||
paginate_by = 30
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return IncomingBatch.objects.all().select_related('warehouse').order_by('-created_at')
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
# Добавляем количество товаров в каждую партию
|
|
||||||
for batch in context['batches']:
|
|
||||||
batch.items_count = batch.items.count()
|
|
||||||
batch.total_quantity = sum(item.quantity for item in batch.items.all())
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingBatchDetailView(LoginRequiredMixin, DetailView):
|
|
||||||
"""Детальная информация по партии поступления"""
|
|
||||||
model = IncomingBatch
|
|
||||||
template_name = 'inventory/incoming_batch/batch_detail.html'
|
|
||||||
context_object_name = 'batch'
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
batch = self.get_object()
|
|
||||||
|
|
||||||
# Товары в этой партии
|
|
||||||
context['items'] = batch.items.all().select_related('product', 'stock_batch')
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
class StockBatchListView(LoginRequiredMixin, ListView):
|
class StockBatchListView(LoginRequiredMixin, ListView):
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.db.models import Q, Sum, Count
|
from django.db.models import Q, Sum, Count
|
||||||
from inventory.models import StockBatch, Stock, Reservation, Sale, SaleBatchAllocation, WriteOff, WriteOffDocument, WriteOffDocumentItem
|
from inventory.models import (
|
||||||
|
StockBatch, Stock, Reservation, Sale, SaleBatchAllocation, WriteOff,
|
||||||
|
WriteOffDocument, WriteOffDocumentItem, IncomingDocument, IncomingDocumentItem
|
||||||
|
)
|
||||||
from orders.models import Order
|
from orders.models import Order
|
||||||
from products.models import Product
|
from products.models import Product
|
||||||
from inventory.models import Warehouse
|
from inventory.models import Warehouse
|
||||||
@@ -44,6 +47,9 @@ def debug_inventory_page(request):
|
|||||||
writeoff_document_items = WriteOffDocumentItem.objects.select_related(
|
writeoff_document_items = WriteOffDocumentItem.objects.select_related(
|
||||||
'product', 'document__warehouse'
|
'product', 'document__warehouse'
|
||||||
).order_by('-id')
|
).order_by('-id')
|
||||||
|
# Документы поступления
|
||||||
|
incoming_documents = IncomingDocument.objects.select_related('warehouse', 'created_by', 'confirmed_by').order_by('-date', '-created_at')
|
||||||
|
incoming_document_items = IncomingDocumentItem.objects.select_related('product', 'document__warehouse').order_by('-id')
|
||||||
orders = Order.objects.prefetch_related('items').order_by('-created_at')
|
orders = Order.objects.prefetch_related('items').order_by('-created_at')
|
||||||
|
|
||||||
# Применяем фильтры
|
# Применяем фильтры
|
||||||
@@ -56,6 +62,7 @@ def debug_inventory_page(request):
|
|||||||
allocations = allocations.filter(sale__product_id=product_id)
|
allocations = allocations.filter(sale__product_id=product_id)
|
||||||
writeoffs = writeoffs.filter(batch__product_id=product_id)
|
writeoffs = writeoffs.filter(batch__product_id=product_id)
|
||||||
writeoff_document_items = writeoff_document_items.filter(product_id=product_id)
|
writeoff_document_items = writeoff_document_items.filter(product_id=product_id)
|
||||||
|
incoming_document_items = incoming_document_items.filter(product_id=product_id)
|
||||||
orders = orders.filter(items__product_id=product_id).distinct()
|
orders = orders.filter(items__product_id=product_id).distinct()
|
||||||
else:
|
else:
|
||||||
product = None
|
product = None
|
||||||
@@ -96,6 +103,8 @@ def debug_inventory_page(request):
|
|||||||
writeoffs = writeoffs.filter(batch__warehouse_id=warehouse_id)
|
writeoffs = writeoffs.filter(batch__warehouse_id=warehouse_id)
|
||||||
writeoff_documents = writeoff_documents.filter(warehouse_id=warehouse_id)
|
writeoff_documents = writeoff_documents.filter(warehouse_id=warehouse_id)
|
||||||
writeoff_document_items = writeoff_document_items.filter(document__warehouse_id=warehouse_id)
|
writeoff_document_items = writeoff_document_items.filter(document__warehouse_id=warehouse_id)
|
||||||
|
incoming_documents = incoming_documents.filter(warehouse_id=warehouse_id)
|
||||||
|
incoming_document_items = incoming_document_items.filter(document__warehouse_id=warehouse_id)
|
||||||
else:
|
else:
|
||||||
warehouse = None
|
warehouse = None
|
||||||
|
|
||||||
@@ -108,6 +117,8 @@ def debug_inventory_page(request):
|
|||||||
writeoffs = writeoffs[:100]
|
writeoffs = writeoffs[:100]
|
||||||
writeoff_documents = writeoff_documents[:50]
|
writeoff_documents = writeoff_documents[:50]
|
||||||
writeoff_document_items = writeoff_document_items[:100]
|
writeoff_document_items = writeoff_document_items[:100]
|
||||||
|
incoming_documents = incoming_documents[:50]
|
||||||
|
incoming_document_items = incoming_document_items[:100]
|
||||||
orders = orders[:50]
|
orders = orders[:50]
|
||||||
|
|
||||||
# Списки для фильтров
|
# Списки для фильтров
|
||||||
@@ -123,6 +134,8 @@ def debug_inventory_page(request):
|
|||||||
'writeoffs': writeoffs,
|
'writeoffs': writeoffs,
|
||||||
'writeoff_documents': writeoff_documents,
|
'writeoff_documents': writeoff_documents,
|
||||||
'writeoff_document_items': writeoff_document_items,
|
'writeoff_document_items': writeoff_document_items,
|
||||||
|
'incoming_documents': incoming_documents,
|
||||||
|
'incoming_document_items': incoming_document_items,
|
||||||
'orders': orders,
|
'orders': orders,
|
||||||
'products': products,
|
'products': products,
|
||||||
'warehouses': warehouses,
|
'warehouses': warehouses,
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ def get_queryset(self):
|
|||||||
|
|
||||||
## 🟡 Средний приоритет
|
## 🟡 Средний приоритет
|
||||||
|
|
||||||
### 4. Рефакторинг модельной избыточности
|
### 4. ✅ Рефакторинг модельной избыточности (ВЫПОЛНЕНО)
|
||||||
**Проблема:** IncomingDocument → IncomingBatch → Incoming создает 3 уровня данных
|
**Проблема:** IncomingDocument → IncomingBatch → Incoming создавало 3 уровня данных
|
||||||
**Решение:** Долгосрочная миграция к упрощенной структуре
|
**Решение:** ✅ Миграция к упрощенной структуре завершена
|
||||||
|
|
||||||
**Архитектура будущего:**
|
**Текущая архитектура:**
|
||||||
```
|
```
|
||||||
IncomingDocument (документ)
|
IncomingDocument (документ)
|
||||||
↓
|
↓
|
||||||
@@ -69,12 +69,14 @@ IncomingDocumentItem (позиции документа)
|
|||||||
StockBatch (напрямую создается из items при подтверждении)
|
StockBatch (напрямую создается из items при подтверждении)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Преимущества:**
|
**Достигнутые результаты:**
|
||||||
- Убрать промежуточные Incoming/IncomingBatch
|
- ✅ Удалены промежуточные модели Incoming/IncomingBatch
|
||||||
- Упростить код сигналов
|
- ✅ Упрощен код сигналов (удалены create_stock_batch_on_incoming и update_stock_batch_on_incoming_edit)
|
||||||
- Меньше JOIN'ов в запросах
|
- ✅ Упрощен IncomingDocumentService.confirm_document() - напрямую создает StockBatch
|
||||||
|
- ✅ Меньше JOIN'ов в запросах
|
||||||
|
- ✅ Применены миграции БД для удаления таблиц
|
||||||
|
|
||||||
**Миграция:** Постепенная, требует переписывания signals и services
|
**Дата выполнения:** 2025-12-26
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ def add_items_bulk(document, items_data):
|
|||||||
1. ✅ **Неделя 1:** Безопасность (права доступ, п.1)
|
1. ✅ **Неделя 1:** Безопасность (права доступ, п.1)
|
||||||
2. ✅ **Неделя 2:** Тесты (критические пути, п.2)
|
2. ✅ **Неделя 2:** Тесты (критические пути, п.2)
|
||||||
3. ✅ **Неделя 3:** Производительность (N+1, п.3)
|
3. ✅ **Неделя 3:** Производительность (N+1, п.3)
|
||||||
4. 📅 **Квартал 2:** Рефакторинг моделей (п.4)
|
4. ✅ **26.12.2025:** Рефакторинг моделей (п.4) - избавились от лишних сущностей
|
||||||
5. 📅 **По необходимости:** Bulk операции (п.5), документация (п.6)
|
5. 📅 **По необходимости:** Bulk операции (п.5), документация (п.6)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user