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:
2025-11-15 00:22:58 +03:00
parent 4a4bd437b9
commit 65a316649b
4 changed files with 216 additions and 9 deletions

View File

@@ -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 - Перемещение товаров между складами
# ============================================================================

View File

@@ -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):
"""

View File

@@ -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>

View File

@@ -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)