feat(pos): add write-off functionality for showcase kits
Add support for writing off showcase kits by creating a write-off document with components, converting reservations, and updating statuses. - Add `write_off_from_showcase` static method to ShowcaseManager - Add API endpoint `/pos/api/product-kits/<int:kit_id>/write-off/` - Add write-off button to POS terminal UI - Implement confirmation dialog with detailed information - Add redirect to write-off document detail page after success The write-off process includes: 1. Creating a write-off document in draft state 2. Converting existing reservations to write-off document items 3. Marking the showcase item as dismantled 4. Setting the product kit status to discontinued (if not already) Breaking Changes: No
This commit is contained in:
@@ -666,6 +666,113 @@ class ShowcaseManager:
|
|||||||
'message': f'Ошибка разбора: {str(e)}'
|
'message': f'Ошибка разбора: {str(e)}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_off_from_showcase(showcase_item, reason='spoilage', notes=None, created_by=None):
|
||||||
|
"""
|
||||||
|
Списывает экземпляр витринного комплекта:
|
||||||
|
1. Создаёт документ списания с компонентами комплекта
|
||||||
|
2. Преобразует резервы комплекта в позиции документа списания
|
||||||
|
3. Помечает экземпляр как разобранный
|
||||||
|
|
||||||
|
Args:
|
||||||
|
showcase_item: ShowcaseItem - экземпляр для списания
|
||||||
|
reason: str - причина списания (spoilage по умолчанию)
|
||||||
|
notes: str - примечания
|
||||||
|
created_by: User - пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'items_count': int,
|
||||||
|
'message': str,
|
||||||
|
'error': str (при ошибке)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from inventory.services.writeoff_document_service import WriteOffDocumentService
|
||||||
|
|
||||||
|
# Проверка статуса
|
||||||
|
if showcase_item.status == 'sold':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Нельзя списать проданный экземпляр'
|
||||||
|
}
|
||||||
|
|
||||||
|
if showcase_item.status == 'dismantled':
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': 'Экземпляр уже разобран'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
warehouse = showcase_item.showcase.warehouse
|
||||||
|
product_kit = showcase_item.product_kit
|
||||||
|
|
||||||
|
# Создаём документ списания (черновик)
|
||||||
|
document = WriteOffDocumentService.create_document(
|
||||||
|
warehouse=warehouse,
|
||||||
|
date=timezone.now().date(),
|
||||||
|
notes=f'Списание витринного комплекта: {product_kit.name}',
|
||||||
|
created_by=created_by
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем резервы этого экземпляра
|
||||||
|
reservations = Reservation.objects.filter(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
status='reserved'
|
||||||
|
).select_related('product')
|
||||||
|
|
||||||
|
items_count = 0
|
||||||
|
|
||||||
|
for reservation in reservations:
|
||||||
|
# Добавляем позицию в документ списания
|
||||||
|
# Используем add_item без создания резерва (меняем статус существующего)
|
||||||
|
from inventory.models import WriteOffDocumentItem
|
||||||
|
|
||||||
|
item = WriteOffDocumentItem.objects.create(
|
||||||
|
document=document,
|
||||||
|
product=reservation.product,
|
||||||
|
quantity=reservation.quantity,
|
||||||
|
reason=reason,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Привязываем существующий резерв к позиции документа
|
||||||
|
reservation.writeoff_document_item = item
|
||||||
|
reservation.status = 'converted_to_writeoff'
|
||||||
|
reservation.converted_at = timezone.now()
|
||||||
|
reservation.save(update_fields=['writeoff_document_item', 'status', 'converted_at'])
|
||||||
|
|
||||||
|
items_count += 1
|
||||||
|
|
||||||
|
# Помечаем экземпляр как разобранный
|
||||||
|
showcase_item.status = 'dismantled'
|
||||||
|
showcase_item.save(update_fields=['status'])
|
||||||
|
|
||||||
|
# Помечаем шаблон комплекта как снятый
|
||||||
|
if product_kit.status != 'discontinued':
|
||||||
|
product_kit.status = 'discontinued'
|
||||||
|
product_kit.save(update_fields=['status'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'document_id': document.id,
|
||||||
|
'document_number': document.document_number,
|
||||||
|
'items_count': items_count,
|
||||||
|
'message': f'Создан документ {document.document_number} с {items_count} позициями'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'document_id': None,
|
||||||
|
'message': f'Ошибка списания: {str(e)}'
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_showcase_items_for_pos(showcase=None):
|
def get_showcase_items_for_pos(showcase=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1923,6 +1923,7 @@ async function openEditKitModal(kitId) {
|
|||||||
|
|
||||||
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
|
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
|
||||||
document.getElementById('disassembleKitBtn').style.display = 'block';
|
document.getElementById('disassembleKitBtn').style.display = 'block';
|
||||||
|
document.getElementById('writeOffKitBtn').style.display = 'block';
|
||||||
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
|
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
|
||||||
document.getElementById('addProductBlock').style.display = 'block';
|
document.getElementById('addProductBlock').style.display = 'block';
|
||||||
|
|
||||||
@@ -2554,6 +2555,53 @@ document.getElementById('disassembleKitBtn').addEventListener('click', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Обработчик кнопки "Списать букет"
|
||||||
|
document.getElementById('writeOffKitBtn').addEventListener('click', async () => {
|
||||||
|
if (!isEditMode || !editingKitId) {
|
||||||
|
alert('Ошибка: режим редактирования не активен');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос подтверждения
|
||||||
|
const confirmed = confirm(
|
||||||
|
'Вы уверены?\n\n' +
|
||||||
|
'Букет будет списан:\n' +
|
||||||
|
'• Будет создан документ списания с компонентами букета\n' +
|
||||||
|
'• Комплект будет помечен как "Снят"\n' +
|
||||||
|
'• Будет открыта страница документа для редактирования\n\n' +
|
||||||
|
'Продолжить?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/pos/api/product-kits/${editingKitId}/write-off/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Закрываем модальное окно
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Перенаправляем на страницу документа
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} else {
|
||||||
|
alert(`❌ Ошибка: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error writing off kit:', error);
|
||||||
|
alert('Произошла ошибка при списании букета');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Вспомогательная функция для определения мобильного устройства
|
// Вспомогательная функция для определения мобильного устройства
|
||||||
function isMobileDevice() {
|
function isMobileDevice() {
|
||||||
// Проверяем по юзер-агенту и размеру экрана
|
// Проверяем по юзер-агенту и размеру экрана
|
||||||
@@ -2616,8 +2664,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';
|
document.getElementById('disassembleKitBtn').style.display = 'none';
|
||||||
|
document.getElementById('writeOffKitBtn').style.display = 'none';
|
||||||
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
|
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
|
||||||
document.getElementById('addProductBlock').style.display = 'none';
|
document.getElementById('addProductBlock').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,11 @@
|
|||||||
<i class="bi bi-scissors"></i> Разобрать букет
|
<i class="bi bi-scissors"></i> Разобрать букет
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Кнопка "Списать" (отображается только в режиме редактирования) -->
|
||||||
|
<button type="button" class="btn btn-warning me-auto" id="writeOffKitBtn" style="display: none;">
|
||||||
|
<i class="bi bi-file-earmark-x"></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">
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ urlpatterns = [
|
|||||||
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]
|
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
|
||||||
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
|
||||||
|
# Списать витринный комплект (создать документ списания с компонентами) [POST]
|
||||||
|
path('api/product-kits/<int:kit_id>/write-off/', views.write_off_showcase_kit, name='write-off-showcase-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'),
|
||||||
# Создать заказ и провести оплату в POS [POST]
|
# Создать заказ и провести оплату в POS [POST]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -13,7 +14,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from products.models import Product, ProductCategory, ProductKit, KitItem
|
from products.models import Product, ProductCategory, ProductKit, KitItem
|
||||||
from inventory.models import Showcase, Reservation, Warehouse, Stock
|
from inventory.models import Showcase, Reservation, Warehouse, Stock, ShowcaseItem
|
||||||
from inventory.services import ShowcaseManager
|
from inventory.services import ShowcaseManager
|
||||||
from inventory.signals import skip_sale_creation, reset_sale_creation
|
from inventory.signals import skip_sale_creation, reset_sale_creation
|
||||||
|
|
||||||
@@ -1536,6 +1537,88 @@ def disassemble_product_kit(request, kit_id):
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def write_off_showcase_kit(request, kit_id):
|
||||||
|
"""
|
||||||
|
Списывает витринный комплект с созданием документа списания.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP запрос
|
||||||
|
kit_id: ID комплекта для списания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
'success': bool,
|
||||||
|
'document_id': int,
|
||||||
|
'document_number': str,
|
||||||
|
'redirect_url': str,
|
||||||
|
'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)
|
||||||
|
|
||||||
|
# Находим экземпляр на витрине
|
||||||
|
showcase_item = ShowcaseItem.objects.filter(
|
||||||
|
showcase=kit.showcase,
|
||||||
|
product_kit=kit,
|
||||||
|
status='available'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not showcase_item:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Экземпляр комплекта не найден на витрине'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Создаём документ списания
|
||||||
|
result = ShowcaseManager.write_off_from_showcase(
|
||||||
|
showcase_item=showcase_item,
|
||||||
|
reason='spoilage',
|
||||||
|
notes=f'Витринный букет: {kit.name}',
|
||||||
|
created_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result['success']:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': result['message']
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Формируем URL для перенаправления
|
||||||
|
redirect_url = reverse('inventory:writeoff-document-detail', kwargs={'pk': result['document_id']})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'document_id': result['document_id'],
|
||||||
|
'document_number': result['document_number'],
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
'message': result['message']
|
||||||
|
})
|
||||||
|
|
||||||
|
except ProductKit.DoesNotExist:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def pos_checkout(request):
|
def pos_checkout(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user