Fix Product filtering and add kit disassembly functionality
Fixed: - Replace is_active with status='active' for Product filtering in IncomingModelForm - Product model uses status field instead of is_active Added: - Showcase field to ProductKit for tracking showcase placement - product_kit field to Reservation for tracking kit-specific reservations - Disassemble button in POS terminal for showcase kits - API endpoint for kit disassembly (release reservations, mark discontinued) - Improved reservation filtering when dismantling specific kits Changes: - ShowcaseManager now links reservations to specific kit instances - POS terminal modal shows disassemble button in edit mode - Kit disassembly properly updates stock aggregates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -328,7 +328,7 @@ class IncomingModelForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Фильтруем только активные товары
|
# Фильтруем только активные товары
|
||||||
self.fields['product'].queryset = Product.objects.filter(
|
self.fields['product'].queryset = Product.objects.filter(
|
||||||
is_active=True
|
status='active'
|
||||||
).order_by('name')
|
).order_by('name')
|
||||||
|
|
||||||
def clean_quantity(self):
|
def clean_quantity(self):
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-11-20 12:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0004_showcase_is_default_and_more'),
|
||||||
|
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
|
||||||
|
('products', '0008_productkit_showcase_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='product_kit',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Временный комплект, для которого создан резерв', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='products.productkit', verbose_name='Комплект'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='reservation',
|
||||||
|
index=models.Index(fields=['product_kit'], name='inventory_r_product_70aed5_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -412,6 +412,10 @@ class Reservation(models.Model):
|
|||||||
related_name='reservations', verbose_name="Витрина",
|
related_name='reservations', verbose_name="Витрина",
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
help_text="Витрина, на которой выложен букет")
|
help_text="Витрина, на которой выложен букет")
|
||||||
|
product_kit = models.ForeignKey('products.ProductKit', on_delete=models.CASCADE,
|
||||||
|
related_name='reservations', verbose_name="Комплект",
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Временный комплект, для которого создан резерв")
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||||
related_name='reservations', verbose_name="Товар")
|
related_name='reservations', verbose_name="Товар")
|
||||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||||
@@ -433,11 +437,14 @@ class Reservation(models.Model):
|
|||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
models.Index(fields=['order_item']),
|
models.Index(fields=['order_item']),
|
||||||
models.Index(fields=['showcase']),
|
models.Index(fields=['showcase']),
|
||||||
|
models.Index(fields=['product_kit']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.order_item:
|
if self.order_item:
|
||||||
context = f" (заказ {self.order_item.order.order_number})"
|
context = f" (заказ {self.order_item.order.order_number})"
|
||||||
|
elif self.product_kit:
|
||||||
|
context = f" (комплект {self.product_kit.name})"
|
||||||
elif self.showcase:
|
elif self.showcase:
|
||||||
context = f" (витрина {self.showcase.name})"
|
context = f" (витрина {self.showcase.name})"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class ShowcaseManager:
|
|||||||
product=kit_item.product,
|
product=kit_item.product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
showcase=showcase,
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
quantity=component_quantity,
|
quantity=component_quantity,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
@@ -84,6 +85,7 @@ class ShowcaseManager:
|
|||||||
product=first_variant.product,
|
product=first_variant.product,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
showcase=showcase,
|
showcase=showcase,
|
||||||
|
product_kit=product_kit,
|
||||||
quantity=component_quantity,
|
quantity=component_quantity,
|
||||||
status='reserved'
|
status='reserved'
|
||||||
)
|
)
|
||||||
@@ -250,10 +252,8 @@ class ShowcaseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if product_kit:
|
if product_kit:
|
||||||
# Если указан конкретный комплект, фильтруем резервы
|
# Если указан конкретный комплект, фильтруем только его резервы
|
||||||
# TODO: добавить связь резерва с конкретным экземпляром комплекта
|
reservations = reservations.filter(product_kit=product_kit)
|
||||||
# Пока освобождаем все резервы витрины
|
|
||||||
pass
|
|
||||||
|
|
||||||
released_count = reservations.count()
|
released_count = reservations.count()
|
||||||
|
|
||||||
@@ -264,6 +264,11 @@ class ShowcaseManager:
|
|||||||
'message': f'На витрине "{showcase.name}" нет активных резервов'
|
'message': f'На витрине "{showcase.name}" нет активных резервов'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Сохраняем список затронутых товаров и склад ДО обновления резервов
|
||||||
|
from inventory.models import Stock
|
||||||
|
affected_products = list(reservations.values_list('product_id', flat=True).distinct())
|
||||||
|
warehouse = showcase.warehouse
|
||||||
|
|
||||||
# Освобождаем резервы
|
# Освобождаем резервы
|
||||||
reservations.update(
|
reservations.update(
|
||||||
status='released',
|
status='released',
|
||||||
@@ -272,13 +277,11 @@ class ShowcaseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Обновляем агрегаты Stock
|
# Обновляем агрегаты Stock
|
||||||
from inventory.models import Stock
|
|
||||||
affected_products = reservations.values_list('product_id', flat=True).distinct()
|
|
||||||
for product_id in affected_products:
|
for product_id in affected_products:
|
||||||
try:
|
try:
|
||||||
stock = Stock.objects.get(
|
stock = Stock.objects.get(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
warehouse=showcase.warehouse
|
warehouse=warehouse
|
||||||
)
|
)
|
||||||
stock.refresh_from_batches()
|
stock.refresh_from_batches()
|
||||||
except Stock.DoesNotExist:
|
except Stock.DoesNotExist:
|
||||||
|
|||||||
@@ -895,6 +895,9 @@ async function openEditKitModal(kitId) {
|
|||||||
document.getElementById('createTempKitModalLabel').textContent = 'Редактирование витринного букета';
|
document.getElementById('createTempKitModalLabel').textContent = 'Редактирование витринного букета';
|
||||||
document.getElementById('confirmCreateTempKit').textContent = 'Сохранить изменения';
|
document.getElementById('confirmCreateTempKit').textContent = 'Сохранить изменения';
|
||||||
|
|
||||||
|
// Показываем кнопку "Разобрать" в режиме редактирования
|
||||||
|
document.getElementById('disassembleKitBtn').style.display = 'block';
|
||||||
|
|
||||||
// Открываем модальное окно
|
// Открываем модальное окно
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
|
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
@@ -1234,6 +1237,58 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработчик кнопки "Разобрать букет"
|
||||||
|
document.getElementById('disassembleKitBtn').addEventListener('click', async () => {
|
||||||
|
if (!isEditMode || !editingKitId) {
|
||||||
|
alert('Ошибка: режим редактирования не активен');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос подтверждения
|
||||||
|
const confirmed = confirm(
|
||||||
|
'Вы уверены?\n\n' +
|
||||||
|
'Букет будет разобран:\n' +
|
||||||
|
'• Все резервы компонентов будут освобождены\n' +
|
||||||
|
'• Комплект будет помечен как "Снят"\n\n' +
|
||||||
|
'Это действие нельзя отменить!'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/pos/api/product-kits/${editingKitId}/disassemble/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(`✅ ${data.message}\n\nОсвобождено резервов: ${data.released_count}`);
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Обновляем витринные комплекты
|
||||||
|
isShowcaseView = true;
|
||||||
|
currentCategoryId = null;
|
||||||
|
await refreshShowcaseKits();
|
||||||
|
renderCategories();
|
||||||
|
renderProducts();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Ошибка: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disassembling kit:', error);
|
||||||
|
alert('Произошла ошибка при разборе букета');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Вспомогательная функция для получения CSRF токена
|
// Вспомогательная функция для получения CSRF токена
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
let cookieValue = null;
|
let cookieValue = null;
|
||||||
@@ -1263,6 +1318,9 @@ document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal'
|
|||||||
// Восстанавливаем заголовок и текст кнопки
|
// Восстанавливаем заголовок и текст кнопки
|
||||||
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
|
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
|
||||||
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
||||||
|
|
||||||
|
// Скрываем кнопку "Разобрать"
|
||||||
|
document.getElementById('disassembleKitBtn').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<!-- Кнопка "Разобрать" (отображается только в режиме редактирования) -->
|
||||||
|
<button type="button" class="btn btn-danger me-auto" id="disassembleKitBtn" style="display: none;">
|
||||||
|
<i class="bi bi-scissors"></i> Разобрать букет
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Правая группа кнопок -->
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
|
||||||
<i class="bi bi-check-circle"></i> Создать и зарезервировать
|
<i class="bi bi-check-circle"></i> Создать и зарезервировать
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ urlpatterns = [
|
|||||||
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
|
path('api/product-kits/<int:kit_id>/', views.get_product_kit_details, name='get-product-kit-details'),
|
||||||
# Обновить временный комплект (состав, фото, цены) [POST]
|
# Обновить временный комплект (состав, фото, цены) [POST]
|
||||||
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
|
||||||
|
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
||||||
|
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
||||||
# Создать временный комплект и зарезервировать на витрину [POST]
|
# Создать временный комплект и зарезервировать на витрину [POST]
|
||||||
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
|
||||||
]
|
]
|
||||||
@@ -716,7 +716,8 @@ def create_temp_kit_to_showcase(request):
|
|||||||
status='active',
|
status='active',
|
||||||
price_adjustment_type=price_adjustment_type,
|
price_adjustment_type=price_adjustment_type,
|
||||||
price_adjustment_value=price_adjustment_value,
|
price_adjustment_value=price_adjustment_value,
|
||||||
sale_price=sale_price
|
sale_price=sale_price,
|
||||||
|
showcase=showcase
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Создаём KitItem для каждого товара из корзины
|
# 2. Создаём KitItem для каждого товара из корзины
|
||||||
@@ -911,3 +912,73 @@ def update_product_kit(request, kit_id):
|
|||||||
return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400)
|
return JsonResponse({'success': False, 'error': 'Неверный формат данных'}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def disassemble_product_kit(request, kit_id):
|
||||||
|
"""
|
||||||
|
Разбирает витринный комплект: освобождает резервы и устанавливает статус 'discontinued'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP запрос
|
||||||
|
kit_id: ID комплекта для разбора
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'released_count': int,
|
||||||
|
'message': str,
|
||||||
|
'error': str (если failed)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Получаем комплект с витриной (только временные комплекты)
|
||||||
|
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
|
||||||
|
|
||||||
|
# Проверяем, что комплект ещё не разобран
|
||||||
|
if kit.status == 'discontinued':
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект уже разобран (статус: Снят)'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Проверяем, что у комплекта есть привязанная витрина
|
||||||
|
if not kit.showcase:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект не привязан к витрине'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Освобождаем резервы и устанавливаем статус
|
||||||
|
# ShowcaseManager.dismantle_from_showcase уже использует transaction.atomic()
|
||||||
|
result = ShowcaseManager.dismantle_from_showcase(
|
||||||
|
showcase=kit.showcase,
|
||||||
|
product_kit=kit
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result['success']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': result['message']
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Устанавливаем статус комплекта 'discontinued'
|
||||||
|
kit.discontinue(user=request.user)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'released_count': result['released_count'],
|
||||||
|
'message': f'Комплект "{kit.name}" разобран. Статус изменён на "Снят".'
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Комплект не найден'
|
||||||
|
}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Ошибка при разборе: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|||||||
@@ -516,9 +516,6 @@ class ProductKitAdmin(admin.ModelAdmin):
|
|||||||
filter_horizontal = ('categories', 'tags')
|
filter_horizontal = ('categories', 'tags')
|
||||||
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
|
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
|
||||||
actions = [
|
actions = [
|
||||||
restore_items,
|
|
||||||
delete_selected,
|
|
||||||
hard_delete_selected,
|
|
||||||
show_poor_quality_photos,
|
show_poor_quality_photos,
|
||||||
show_excellent_quality_photos,
|
show_excellent_quality_photos,
|
||||||
show_all_quality_levels,
|
show_all_quality_levels,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-11-20 11:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0004_showcase_is_default_and_more'),
|
||||||
|
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
|
||||||
|
('products', '0007_add_kit_to_attribute'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='productkit',
|
||||||
|
name='showcase',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='productkit',
|
||||||
|
index=models.Index(fields=['showcase'], name='products_pr_showcas_08c1ca_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -104,12 +104,23 @@ class ProductKit(BaseProductEntity):
|
|||||||
help_text="Заказ, для которого создан временный комплект"
|
help_text="Заказ, для которого создан временный комплект"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
showcase = models.ForeignKey(
|
||||||
|
'inventory.Showcase',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='temporary_kits',
|
||||||
|
verbose_name="Витрина",
|
||||||
|
help_text="Витрина, на которой выложен временный комплект"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Комплект"
|
verbose_name = "Комплект"
|
||||||
verbose_name_plural = "Комплекты"
|
verbose_name_plural = "Комплекты"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_temporary']),
|
models.Index(fields=['is_temporary']),
|
||||||
models.Index(fields=['order']),
|
models.Index(fields=['order']),
|
||||||
|
models.Index(fields=['showcase']),
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
# Уникальное имя для активных комплектов (исключаем архивированные и снятые)
|
# Уникальное имя для активных комплектов (исключаем архивированные и снятые)
|
||||||
|
|||||||
Reference in New Issue
Block a user