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 inventory.models import (
|
||||
Warehouse, StockBatch, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
|
||||
Warehouse, StockBatch, Sale, WriteOff, Transfer,
|
||||
Inventory, InventoryLine, Reservation, Stock, StockMovement,
|
||||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
||||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
|
||||
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,
|
||||
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} за ед."
|
||||
|
||||
|
||||
class IncomingBatch(models.Model):
|
||||
"""
|
||||
Партия поступления товара (один номер документа = одна партия).
|
||||
Содержит один номер документа и может включать несколько товаров.
|
||||
"""
|
||||
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
|
||||
# Модели IncomingBatch и Incoming удалены - заменены на IncomingDocument/IncomingDocumentItem
|
||||
# Теперь используется упрощенная архитектура:
|
||||
# IncomingDocument → IncomingDocumentItem → StockBatch (напрямую при проведении)
|
||||
|
||||
|
||||
class Sale(models.Model):
|
||||
@@ -1207,8 +1132,8 @@ class IncomingDocument(models.Model):
|
||||
Сценарий использования:
|
||||
1. Создается черновик (draft)
|
||||
2. В течение дня добавляются товары (IncomingDocumentItem)
|
||||
3. В конце смены документ проводится (confirmed) → создаются IncomingBatch и Incoming
|
||||
4. Сигнал автоматически создает StockBatch и обновляет Stock
|
||||
3. В конце смены документ проводится (confirmed) → создается StockBatch напрямую
|
||||
4. Stock автоматически обновляется
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Черновик'),
|
||||
@@ -1216,6 +1141,12 @@ class IncomingDocument(models.Model):
|
||||
('cancelled', 'Отменён'),
|
||||
]
|
||||
|
||||
RECEIPT_TYPE_CHOICES = [
|
||||
('supplier', 'Поступление от поставщика'),
|
||||
('inventory', 'Оприходование при инвентаризации'),
|
||||
('adjustment', 'Оприходование без инвентаризации'),
|
||||
]
|
||||
|
||||
document_number = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
@@ -1245,7 +1176,7 @@ class IncomingDocument(models.Model):
|
||||
|
||||
receipt_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=IncomingBatch.RECEIPT_TYPE_CHOICES,
|
||||
choices=RECEIPT_TYPE_CHOICES,
|
||||
default='supplier',
|
||||
db_index=True,
|
||||
verbose_name="Тип поступления"
|
||||
@@ -1355,9 +1286,8 @@ class IncomingDocumentItem(models.Model):
|
||||
- Резервирование НЕ создается (товар еще не поступил)
|
||||
|
||||
При проведении документа:
|
||||
1. Создается IncomingBatch с номером документа
|
||||
2. Создается Incoming запись для каждого товара
|
||||
3. Сигнал create_stock_batch_on_incoming автоматически создает StockBatch
|
||||
1. Для каждой позиции напрямую создается StockBatch
|
||||
2. Stock автоматически обновляется
|
||||
"""
|
||||
document = models.ForeignKey(
|
||||
IncomingDocument,
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from inventory.models import (
|
||||
IncomingDocument, IncomingDocumentItem, IncomingBatch, Incoming
|
||||
IncomingDocument, IncomingDocumentItem, StockBatch, Stock
|
||||
)
|
||||
from inventory.utils.document_generator import generate_incoming_document_number
|
||||
|
||||
@@ -165,10 +165,9 @@ class IncomingDocumentService:
|
||||
|
||||
Процесс:
|
||||
1. Проверяем что документ - черновик и имеет позиции
|
||||
2. Создаем IncomingBatch с номером документа
|
||||
3. Для каждой позиции создаем Incoming запись
|
||||
4. Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
|
||||
5. Меняем статус документа на 'confirmed'
|
||||
2. Для каждой позиции создаем StockBatch напрямую
|
||||
3. Обновляем Stock
|
||||
4. Меняем статус документа на 'confirmed'
|
||||
|
||||
Args:
|
||||
document: IncomingDocument
|
||||
@@ -188,30 +187,30 @@ class IncomingDocumentService:
|
||||
if not document.items.exists():
|
||||
raise ValidationError("Нельзя провести пустой документ")
|
||||
|
||||
# Создаем IncomingBatch
|
||||
incoming_batch = IncomingBatch.objects.create(
|
||||
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 = []
|
||||
# Создаем StockBatch напрямую для каждого товара
|
||||
batches_created = []
|
||||
total_cost = Decimal('0')
|
||||
|
||||
for item in document.items.select_related('product'):
|
||||
incoming = Incoming.objects.create(
|
||||
batch=incoming_batch,
|
||||
# Создаем партию товара на складе
|
||||
stock_batch = StockBatch.objects.create(
|
||||
product=item.product,
|
||||
warehouse=document.warehouse,
|
||||
quantity=item.quantity,
|
||||
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
|
||||
|
||||
# Обновляем или создаем запись в Stock
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
product=item.product,
|
||||
warehouse=document.warehouse
|
||||
)
|
||||
# Пересчитываем остаток из всех активных партий
|
||||
stock.refresh_from_batches()
|
||||
|
||||
# Обновляем статус документа
|
||||
document.status = 'confirmed'
|
||||
document.confirmed_by = confirmed_by
|
||||
@@ -220,8 +219,7 @@ class IncomingDocumentService:
|
||||
|
||||
return {
|
||||
'document': document,
|
||||
'incoming_batch': incoming_batch,
|
||||
'incomings_created': len(incomings_created),
|
||||
'batches_created': len(batches_created),
|
||||
'total_quantity': document.total_quantity,
|
||||
'total_cost': total_cost
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
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.batch_manager import StockBatchManager
|
||||
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
|
||||
@@ -1046,131 +1046,7 @@ def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Incoming)
|
||||
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
|
||||
)
|
||||
# Сигналы для Incoming удалены - теперь StockBatch создается напрямую в IncomingDocumentService
|
||||
|
||||
|
||||
@receiver(post_save, sender=Sale)
|
||||
|
||||
@@ -534,8 +534,100 @@
|
||||
</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;">
|
||||
<strong>Примечание:</strong> Показаны последние 100 записей для каждой таблицы.
|
||||
<strong>Примечание:</strong> Показаны последние 100 записей для каждой таблицы.
|
||||
Используйте фильтры для уточнения результатов.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,16 +239,16 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Партии поступлений -->
|
||||
<!-- Документы поступлений -->
|
||||
<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="d-flex align-items-center">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0 text-dark">Партии поступлений</h6>
|
||||
<h6 class="mb-0 text-dark">Документы поступлений</h6>
|
||||
<small class="text-muted">История поступлений</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
|
||||
@@ -3,8 +3,6 @@ from django.urls import path
|
||||
from .views import (
|
||||
# Warehouse
|
||||
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
||||
# IncomingBatch
|
||||
IncomingBatchListView, IncomingBatchDetailView,
|
||||
# Sale
|
||||
SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView,
|
||||
# Inventory
|
||||
@@ -64,9 +62,8 @@ urlpatterns = [
|
||||
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
|
||||
path('warehouses/<int:pk>/set-default/', SetDefaultWarehouseView.as_view(), name='warehouse-set-default'),
|
||||
|
||||
# ==================== INCOMING BATCH ====================
|
||||
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
|
||||
path('incoming-batches/<int:pk>/', IncomingBatchDetailView.as_view(), name='incoming-batch-detail'),
|
||||
# ==================== INCOMING BATCH (УДАЛЕНО) ====================
|
||||
# IncomingBatch и Incoming удалены. Используйте IncomingDocument вместо них.
|
||||
|
||||
# ==================== SALE ====================
|
||||
path('sales/', SaleListView.as_view(), name='sale-list'),
|
||||
|
||||
@@ -74,40 +74,30 @@ def _initialize_incoming_counter_if_needed():
|
||||
Вызывается только если счетчик равен 0 (не инициализирован).
|
||||
Thread-safe через select_for_update.
|
||||
"""
|
||||
from inventory.models import IncomingBatch, IncomingDocument
|
||||
from inventory.models import IncomingDocument
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
# Быстрая проверка без блокировки - если счетчик существует и > 0, выходим
|
||||
if DocumentCounter.objects.filter(
|
||||
counter_type='incoming',
|
||||
current_value__gt=0
|
||||
).exists():
|
||||
return
|
||||
|
||||
|
||||
# Только если счетчик не инициализирован - делаем полную проверку с блокировкой
|
||||
with transaction.atomic():
|
||||
counter = DocumentCounter.objects.select_for_update().filter(
|
||||
counter_type='incoming'
|
||||
).first()
|
||||
|
||||
|
||||
# Двойная проверка: возможно другой поток уже инициализировал
|
||||
if counter and counter.current_value > 0:
|
||||
return
|
||||
|
||||
# Собираем все номера документов
|
||||
all_numbers = []
|
||||
|
||||
# Номера из IncomingBatch
|
||||
batch_numbers = IncomingBatch.objects.filter(
|
||||
|
||||
# Собираем все номера документов из IncomingDocument
|
||||
all_numbers = list(IncomingDocument.objects.filter(
|
||||
document_number__startswith='IN-'
|
||||
).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)
|
||||
).values_list('document_number', flat=True))
|
||||
|
||||
if all_numbers:
|
||||
# Извлекаем максимальный номер из всех форматов
|
||||
@@ -135,7 +125,7 @@ def generate_incoming_document_number():
|
||||
Thread-safe через DocumentCounter.
|
||||
|
||||
При первом использовании автоматически инициализирует DocumentCounter
|
||||
максимальным номером из существующих документов (IncomingBatch и IncomingDocument).
|
||||
максимальным номером из существующих документов IncomingDocument.
|
||||
|
||||
Returns:
|
||||
str: Сгенерированный номер документа (например, IN-000001)
|
||||
|
||||
@@ -19,7 +19,7 @@ from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
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 .inventory_ops import (
|
||||
InventoryListView, InventoryCreateView, InventoryDetailView,
|
||||
@@ -57,8 +57,6 @@ __all__ = [
|
||||
'inventory_home',
|
||||
# Warehouse
|
||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
||||
# IncomingBatch
|
||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
||||
# Sale
|
||||
'SaleListView', 'SaleCreateView', 'SaleUpdateView', 'SaleDeleteView', 'SaleDetailView',
|
||||
# Inventory
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Batch views - READ ONLY
|
||||
- IncomingBatch (Партии поступлений)
|
||||
- StockBatch (Партии товара на складе)
|
||||
|
||||
ПРИМЕЧАНИЕ: IncomingBatch и Incoming удалены. Используйте IncomingDocument вместо них.
|
||||
"""
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import IncomingBatch, Incoming, 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
|
||||
from ..models import StockBatch, SaleBatchAllocation, WriteOff
|
||||
|
||||
|
||||
class StockBatchListView(LoginRequiredMixin, ListView):
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.shortcuts import render
|
||||
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 products.models import Product
|
||||
from inventory.models import Warehouse
|
||||
@@ -44,6 +47,9 @@ def debug_inventory_page(request):
|
||||
writeoff_document_items = WriteOffDocumentItem.objects.select_related(
|
||||
'product', 'document__warehouse'
|
||||
).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')
|
||||
|
||||
# Применяем фильтры
|
||||
@@ -56,6 +62,7 @@ def debug_inventory_page(request):
|
||||
allocations = allocations.filter(sale__product_id=product_id)
|
||||
writeoffs = writeoffs.filter(batch__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()
|
||||
else:
|
||||
product = None
|
||||
@@ -96,6 +103,8 @@ def debug_inventory_page(request):
|
||||
writeoffs = writeoffs.filter(batch__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)
|
||||
incoming_documents = incoming_documents.filter(warehouse_id=warehouse_id)
|
||||
incoming_document_items = incoming_document_items.filter(document__warehouse_id=warehouse_id)
|
||||
else:
|
||||
warehouse = None
|
||||
|
||||
@@ -108,6 +117,8 @@ def debug_inventory_page(request):
|
||||
writeoffs = writeoffs[:100]
|
||||
writeoff_documents = writeoff_documents[:50]
|
||||
writeoff_document_items = writeoff_document_items[:100]
|
||||
incoming_documents = incoming_documents[:50]
|
||||
incoming_document_items = incoming_document_items[:100]
|
||||
orders = orders[:50]
|
||||
|
||||
# Списки для фильтров
|
||||
@@ -123,6 +134,8 @@ def debug_inventory_page(request):
|
||||
'writeoffs': writeoffs,
|
||||
'writeoff_documents': writeoff_documents,
|
||||
'writeoff_document_items': writeoff_document_items,
|
||||
'incoming_documents': incoming_documents,
|
||||
'incoming_document_items': incoming_document_items,
|
||||
'orders': orders,
|
||||
'products': products,
|
||||
'warehouses': warehouses,
|
||||
|
||||
@@ -56,11 +56,11 @@ def get_queryset(self):
|
||||
|
||||
## 🟡 Средний приоритет
|
||||
|
||||
### 4. Рефакторинг модельной избыточности
|
||||
**Проблема:** IncomingDocument → IncomingBatch → Incoming создает 3 уровня данных
|
||||
**Решение:** Долгосрочная миграция к упрощенной структуре
|
||||
### 4. ✅ Рефакторинг модельной избыточности (ВЫПОЛНЕНО)
|
||||
**Проблема:** IncomingDocument → IncomingBatch → Incoming создавало 3 уровня данных
|
||||
**Решение:** ✅ Миграция к упрощенной структуре завершена
|
||||
|
||||
**Архитектура будущего:**
|
||||
**Текущая архитектура:**
|
||||
```
|
||||
IncomingDocument (документ)
|
||||
↓
|
||||
@@ -69,12 +69,14 @@ IncomingDocumentItem (позиции документа)
|
||||
StockBatch (напрямую создается из items при подтверждении)
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- Убрать промежуточные Incoming/IncomingBatch
|
||||
- Упростить код сигналов
|
||||
- Меньше JOIN'ов в запросах
|
||||
**Достигнутые результаты:**
|
||||
- ✅ Удалены промежуточные модели Incoming/IncomingBatch
|
||||
- ✅ Упрощен код сигналов (удалены create_stock_batch_on_incoming и update_stock_batch_on_incoming_edit)
|
||||
- ✅ Упрощен IncomingDocumentService.confirm_document() - напрямую создает StockBatch
|
||||
- ✅ Меньше JOIN'ов в запросах
|
||||
- ✅ Применены миграции БД для удаления таблиц
|
||||
|
||||
**Миграция:** Постепенная, требует переписывания signals и services
|
||||
**Дата выполнения:** 2025-12-26
|
||||
|
||||
---
|
||||
|
||||
@@ -135,7 +137,7 @@ def add_items_bulk(document, items_data):
|
||||
1. ✅ **Неделя 1:** Безопасность (права доступ, п.1)
|
||||
2. ✅ **Неделя 2:** Тесты (критические пути, п.2)
|
||||
3. ✅ **Неделя 3:** Производительность (N+1, п.3)
|
||||
4. 📅 **Квартал 2:** Рефакторинг моделей (п.4)
|
||||
4. ✅ **26.12.2025:** Рефакторинг моделей (п.4) - избавились от лишних сущностей
|
||||
5. 📅 **По необходимости:** Bulk операции (п.5), документация (п.6)
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user