feat: Add signal handler for synchronizing Incoming edits with StockBatch
## Changes ### 1. Fixed missing signal handler for Incoming edit (inventory/signals.py) - Added new signal handler `update_stock_batch_on_incoming_edit()` that: - Triggers when Incoming is edited (created=False) - Synchronizes StockBatch with new quantity and cost_price values - Automatically triggers cost price recalculation for the product - Updates Stock (inventory balance) for the warehouse - Includes proper logging and error handling ### 2. Created IncomingModelForm for editing individual incoming items (inventory/forms.py) - New ModelForm: `IncomingModelForm` that: - Inherits from forms.ModelForm (accepts 'instance' parameter required by UpdateView) - Allows editing: product, quantity, cost_price, notes - Includes validation for positive quantity and non-negative cost_price - Filters only active products ### 3. Updated IncomingUpdateView (inventory/views/incoming.py) - Changed form_class from IncomingForm to IncomingModelForm - Updated imports to include IncomingModelForm - Removed obsolete comments from form_valid method ## Architecture When editing an Incoming item: 1. User submits form with new quantity/cost_price 2. form.save() triggers post_save signal (created=False) 3. update_stock_batch_on_incoming_edit() synchronizes StockBatch 4. StockBatch.save() triggers update_product_cost_on_batch_change() 5. Product.cost_price is recalculated with weighted average ## Problem Solved Previously, editing an Incoming item would NOT: - Update the related StockBatch - Recalculate product cost_price - Update warehouse inventory balance - Maintain data consistency between Incoming and StockBatch Now all these operations happen automatically through the signal chain. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,31 @@ from products.models import Product
|
||||
class WarehouseForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Warehouse
|
||||
fields = ['name', 'description', 'is_active', 'is_default']
|
||||
fields = [
|
||||
'name',
|
||||
'description',
|
||||
'street',
|
||||
'building_number',
|
||||
'phone',
|
||||
'email',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'is_pickup_point'
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Название склада'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Описание'}),
|
||||
'street': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Улица'}),
|
||||
'building_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Номер дома'}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+375 (29) 123-45-67'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'email@example.com'}),
|
||||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'is_pickup_point': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
help_texts = {
|
||||
'is_default': 'Автоматически выбирается при создании новых документов',
|
||||
'is_pickup_point': 'Можно ли выбрать этот склад как точку самовывоза заказа',
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +327,41 @@ class IncomingForm(forms.Form):
|
||||
return document_number
|
||||
|
||||
|
||||
class IncomingModelForm(forms.ModelForm):
|
||||
"""
|
||||
ModelForm для редактирования отдельного товара в поступлении (Incoming).
|
||||
Используется в IncomingUpdateView для редактирования существующих товаров.
|
||||
"""
|
||||
class Meta:
|
||||
model = Incoming
|
||||
fields = ['product', 'quantity', 'cost_price', 'notes']
|
||||
widgets = {
|
||||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||||
'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Фильтруем только активные товары
|
||||
self.fields['product'].queryset = Product.objects.filter(
|
||||
is_active=True
|
||||
).order_by('name')
|
||||
|
||||
def clean_quantity(self):
|
||||
quantity = self.cleaned_data.get('quantity')
|
||||
if quantity and quantity <= 0:
|
||||
raise ValidationError('Количество должно быть больше нуля')
|
||||
return quantity
|
||||
|
||||
def clean_cost_price(self):
|
||||
cost_price = self.cleaned_data.get('cost_price')
|
||||
if cost_price and cost_price < 0:
|
||||
raise ValidationError('Цена не может быть отрицательной')
|
||||
return cost_price
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRANSFER FORMS - Перемещение товаров между складами
|
||||
# ============================================================================
|
||||
|
||||
@@ -232,6 +232,81 @@ def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
|
||||
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)
|
||||
def process_sale_fifo(sender, instance, created, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</label>
|
||||
<textarea class="form-control {% if form.description.errors %}is-invalid{% endif %}"
|
||||
id="{{ form.description.id_for_label }}" name="{{ form.description.html_name }}"
|
||||
rows="4">{{ form.description.value|default:'' }}</textarea>
|
||||
rows="3">{{ form.description.value|default:'' }}</textarea>
|
||||
{% if form.description.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{% for error in form.description.errors %}
|
||||
@@ -57,6 +57,72 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 mb-3">Адрес</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="{{ form.street.id_for_label }}" class="form-label">
|
||||
{{ form.street.label }}
|
||||
</label>
|
||||
{{ form.street }}
|
||||
{% if form.street.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.street.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ form.building_number.id_for_label }}" class="form-label">
|
||||
{{ form.building_number.label }}
|
||||
</label>
|
||||
{{ form.building_number }}
|
||||
{% if form.building_number.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.building_number.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 mb-3">Контакты</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||
{{ form.phone.label }}
|
||||
</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.phone.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">
|
||||
{{ form.email.label }}
|
||||
</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{% for error in form.email.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 mb-3">Настройки</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
@@ -76,7 +142,21 @@
|
||||
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
|
||||
{{ form.is_default.label }}
|
||||
<small class="text-muted d-block">
|
||||
Отмечьте, чтобы использовать этот склад по умолчанию при создании новых документов
|
||||
{{ form.is_default.help_text }}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="{{ form.is_pickup_point.id_for_label }}" name="{{ form.is_pickup_point.html_name }}"
|
||||
{% if form.is_pickup_point.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ form.is_pickup_point.id_for_label }}">
|
||||
{{ form.is_pickup_point.label }}
|
||||
<small class="text-muted d-block">
|
||||
{{ form.is_pickup_point.help_text }}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.views.decorators.http import require_http_methods
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db import IntegrityError, transaction
|
||||
from ..models import Incoming, IncomingBatch, Warehouse
|
||||
from ..forms import IncomingForm, IncomingLineForm
|
||||
from ..forms import IncomingForm, IncomingLineForm, IncomingModelForm
|
||||
from inventory.utils import generate_incoming_document_number
|
||||
from products.models import Product
|
||||
|
||||
@@ -47,13 +47,11 @@ class IncomingUpdateView(LoginRequiredMixin, UpdateView):
|
||||
Обработанные приходы редактировать нельзя.
|
||||
"""
|
||||
model = Incoming
|
||||
form_class = IncomingForm
|
||||
form_class = IncomingModelForm
|
||||
template_name = 'inventory/incoming/incoming_form.html'
|
||||
success_url = reverse_lazy('inventory:incoming-list')
|
||||
|
||||
def form_valid(self, form):
|
||||
# При редактировании можем оставить номер пустым - модель генерирует при сохранении
|
||||
# Но это только если объект ещё не имеет номера (новый)
|
||||
messages.success(self.request, f'Приход товара обновлён.')
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user