ShowcaseItem: защита от двойной продажи витринных букетов
Новая архитектура: - ShowcaseItem модель - физический экземпляр букета на витрине - OneToOneField(sold_order_item) - БД-уровневая защита от двойной продажи - Поддержка создания нескольких экземпляров одного букета - Возможность продавать N из M доступных (например 2 из 5) Изменения: - inventory/models.py: добавлена модель ShowcaseItem с методами lock/unlock/mark_sold - inventory/services/showcase_manager.py: переработан для работы с ShowcaseItem - pos/views.py: API поддерживает quantity и showcase_item_ids - pos/templates/pos/terminal.html: поле "Сколько букетов создать" - pos/static/pos/js/terminal.js: выбор количества, передача showcase_item_ids Миграции: - 0007: создание модели ShowcaseItem - 0008: data migration существующих букетов - 0009: очистка ShowcaseItem для уже проданных букетов 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -570,9 +570,10 @@ function renderProducts() {
|
||||
const stock = document.createElement('div');
|
||||
stock.className = 'product-stock';
|
||||
|
||||
// Для витринных комплектов показываем название витрины
|
||||
// Для витринных комплектов показываем название витрины И доступное количество
|
||||
if (item.type === 'showcase_kit') {
|
||||
stock.textContent = `🌺 ${item.showcase_name}`;
|
||||
const availableCount = item.available_count || 1;
|
||||
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge bg-success ms-1">${availableCount} шт</span>`;
|
||||
stock.style.color = '#856404';
|
||||
stock.style.fontWeight = 'bold';
|
||||
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
|
||||
@@ -735,14 +736,34 @@ function setupInfiniteScroll() {
|
||||
async function addToCart(item) {
|
||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock + количество)
|
||||
if (item.type === 'showcase_kit') {
|
||||
// Проверяем: не заблокирован ли уже этим пользователем
|
||||
if (cart.has(cartKey)) {
|
||||
alert('Этот букет уже в вашей корзине.\nВитринные комплекты доступны только в количестве 1 шт.');
|
||||
// Определяем сколько доступно и сколько добавить
|
||||
const availableCount = item.available_count || 1;
|
||||
const currentInCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
const remainingAvailable = availableCount - currentInCart;
|
||||
|
||||
if (remainingAvailable <= 0) {
|
||||
alert(`Все ${availableCount} экз. этого букета уже в корзине.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если доступно > 1, спрашиваем количество
|
||||
let quantityToAdd = 1;
|
||||
if (remainingAvailable > 1 && !cart.has(cartKey)) {
|
||||
const input = prompt(
|
||||
`Доступно ${availableCount} экз. букета "${item.name}".\n` +
|
||||
`Сколько добавить в корзину? (1-${remainingAvailable})`,
|
||||
'1'
|
||||
);
|
||||
if (input === null) return; // Отмена
|
||||
quantityToAdd = parseInt(input, 10);
|
||||
if (isNaN(quantityToAdd) || quantityToAdd < 1 || quantityToAdd > remainingAvailable) {
|
||||
alert(`Введите число от 1 до ${remainingAvailable}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Пытаемся создать блокировку через API
|
||||
try {
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/add-to-cart/`, {
|
||||
@@ -750,7 +771,8 @@ async function addToCart(item) {
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify({ quantity: quantityToAdd })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -761,16 +783,28 @@ async function addToCart(item) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешно заблокировали - добавляем в корзину с qty=1 и флагом max_qty
|
||||
cart.set(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: 1,
|
||||
type: item.type,
|
||||
max_qty: 1, // Флаг: нельзя увеличить количество
|
||||
lock_expires_at: data.lock_expires_at // Время истечения блокировки
|
||||
});
|
||||
// Успешно заблокировали - добавляем/обновляем в корзине
|
||||
const lockedItemIds = data.locked_item_ids || [];
|
||||
|
||||
if (cart.has(cartKey)) {
|
||||
// Добавляем к существующим
|
||||
const existing = cart.get(cartKey);
|
||||
existing.qty += lockedItemIds.length;
|
||||
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
|
||||
existing.max_qty = availableCount;
|
||||
} else {
|
||||
// Создаём новую запись
|
||||
cart.set(cartKey, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: Number(item.price),
|
||||
qty: lockedItemIds.length,
|
||||
type: item.type,
|
||||
max_qty: availableCount, // Максимум = сколько доступно
|
||||
showcase_item_ids: lockedItemIds, // ID заблокированных экземпляров
|
||||
lock_expires_at: data.lock_expires_at
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем список витрины (чтобы показать блокировку)
|
||||
if (isShowcaseView) {
|
||||
@@ -963,12 +997,19 @@ async function removeFromCart(cartKey) {
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА для витринных комплектов - снимаем блокировку
|
||||
if (item && item.type === 'showcase_kit') {
|
||||
try {
|
||||
// Передаём конкретные showcase_item_ids для снятия блокировки
|
||||
const body = {};
|
||||
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
|
||||
body.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
|
||||
const response = await fetch(`/pos/api/showcase-kits/${item.id}/remove-from-cart/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -1376,11 +1417,15 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
const useSalePrice = document.getElementById('useSalePrice').checked;
|
||||
const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0;
|
||||
|
||||
// Получаем количество букетов для создания
|
||||
const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1;
|
||||
|
||||
// Формируем FormData для отправки с файлом
|
||||
const formData = new FormData();
|
||||
formData.append('kit_name', kitName);
|
||||
if (showcaseId) {
|
||||
formData.append('showcase_id', showcaseId);
|
||||
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
|
||||
}
|
||||
formData.append('description', description);
|
||||
formData.append('items', JSON.stringify(items));
|
||||
@@ -1423,19 +1468,21 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
|
||||
if (data.success) {
|
||||
// Успех!
|
||||
const createdCount = data.available_count || 1;
|
||||
const qtyInfo = createdCount > 1 ? `\nСоздано экземпляров: ${createdCount}` : '';
|
||||
const successMessage = isEditMode
|
||||
? `✅ ${data.message}\n\nКомплект: ${data.kit_name}\nЦена: ${data.kit_price} руб.`
|
||||
: `✅ ${data.message}
|
||||
|
||||
Комплект: ${data.kit_name}
|
||||
Цена: ${data.kit_price} руб.
|
||||
Цена: ${data.kit_price} руб.${qtyInfo}
|
||||
Зарезервировано компонентов: ${data.reservations_count}`;
|
||||
|
||||
|
||||
alert(successMessage);
|
||||
|
||||
|
||||
// Очищаем tempCart (изолированное состояние модалки)
|
||||
tempCart.clear();
|
||||
|
||||
|
||||
// Сбрасываем поля формы
|
||||
document.getElementById('tempKitDescription').value = '';
|
||||
document.getElementById('tempKitPhoto').value = '';
|
||||
@@ -1446,6 +1493,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
document.getElementById('useSalePrice').checked = false;
|
||||
document.getElementById('salePrice').value = '';
|
||||
document.getElementById('salePriceBlock').style.display = 'none';
|
||||
document.getElementById('showcaseKitQuantity').value = '1'; // Сброс количества
|
||||
|
||||
// Сбрасываем режим редактирования
|
||||
isEditMode = false;
|
||||
@@ -1714,12 +1762,19 @@ async function handleCheckoutSubmit(paymentsData) {
|
||||
const orderData = {
|
||||
customer_id: customer.id,
|
||||
warehouse_id: currentWarehouse.id,
|
||||
items: Array.from(cart.values()).map(item => ({
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price
|
||||
})),
|
||||
items: Array.from(cart.values()).map(item => {
|
||||
const itemData = {
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price
|
||||
};
|
||||
// Для витринных букетов передаём ID конкретных экземпляров
|
||||
if (item.type === 'showcase_kit' && item.showcase_item_ids) {
|
||||
itemData.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
return itemData;
|
||||
}),
|
||||
payments: paymentsData,
|
||||
notes: document.getElementById('orderNote').value.trim()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user