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 WarehouseForm(forms.ModelForm):
class Meta: class Meta:
model = Warehouse 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 = { widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}), 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Название склада'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), '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_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_default': 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 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 - Перемещение товаров между складами # TRANSFER FORMS - Перемещение товаров между складами
# ============================================================================ # ============================================================================

View File

@@ -232,6 +232,81 @@ def create_stock_batch_on_incoming(sender, instance, created, **kwargs):
stock.refresh_from_batches() 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)
def process_sale_fifo(sender, instance, created, **kwargs): def process_sale_fifo(sender, instance, created, **kwargs):
""" """

View File

@@ -47,7 +47,7 @@
</label> </label>
<textarea class="form-control {% if form.description.errors %}is-invalid{% endif %}" <textarea class="form-control {% if form.description.errors %}is-invalid{% endif %}"
id="{{ form.description.id_for_label }}" name="{{ form.description.html_name }}" 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 %} {% if form.description.errors %}
<div class="invalid-feedback"> <div class="invalid-feedback">
{% for error in form.description.errors %} {% for error in form.description.errors %}
@@ -57,6 +57,72 @@
{% endif %} {% endif %}
</div> </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="mb-3">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" <input type="checkbox" class="form-check-input"
@@ -76,7 +142,21 @@
<label class="form-check-label" for="{{ form.is_default.id_for_label }}"> <label class="form-check-label" for="{{ form.is_default.id_for_label }}">
{{ form.is_default.label }} {{ form.is_default.label }}
<small class="text-muted d-block"> <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> </small>
</label> </label>
</div> </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.utils.decorators import method_decorator
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from ..models import Incoming, IncomingBatch, Warehouse 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 inventory.utils import generate_incoming_document_number
from products.models import Product from products.models import Product
@@ -47,13 +47,11 @@ class IncomingUpdateView(LoginRequiredMixin, UpdateView):
Обработанные приходы редактировать нельзя. Обработанные приходы редактировать нельзя.
""" """
model = Incoming model = Incoming
form_class = IncomingForm form_class = IncomingModelForm
template_name = 'inventory/incoming/incoming_form.html' template_name = 'inventory/incoming/incoming_form.html'
success_url = reverse_lazy('inventory:incoming-list') success_url = reverse_lazy('inventory:incoming-list')
def form_valid(self, form): def form_valid(self, form):
# При редактировании можем оставить номер пустым - модель генерирует при сохранении
# Но это только если объект ещё не имеет номера (новый)
messages.success(self.request, f'Приход товара обновлён.') messages.success(self.request, f'Приход товара обновлён.')
return super().form_valid(form) return super().form_valid(form)