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 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 - Перемещение товаров между складами
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user