Files
octopus/myproject/pos/static/pos/js/cart.js

542 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Модуль управления корзиной POS Terminal
* Класс Cart инкапсулирует всю логику работы с корзиной
*/
import CONFIG from './config.js';
import {
roundQuantity,
getCsrfToken,
showToast,
safeFetch,
formatMoney
} from './utils.js';
/**
* @typedef {Object} CartItem
* @property {number} id - ID товара/комплекта
* @property {string} name - Название
* @property {number} price - Цена
* @property {number} qty - Количество
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
* @property {number} [sales_unit_id] - ID единицы продажи
* @property {string} [unit_name] - Название единицы продажи
* @property {number} [quantity_step] - Шаг количества
* @property {boolean} [price_overridden] - Цена изменена вручную
* @property {number[]} [showcase_item_ids] - ID витринных экземпляров
* @property {string} [lock_expires_at] - Время истечения блокировки
*/
export class Cart {
constructor() {
/** @type {Map<string, CartItem>} */
this.items = new Map();
/** @type {number|null} */
this.saveTimeout = null;
/** @type {Function[]} */
this.listeners = [];
}
/**
* Добавляет слушатель изменений корзины
* @param {Function} callback - Функция обратного вызова
*/
addListener(callback) {
this.listeners.push(callback);
}
/**
* Удаляет слушатель изменений корзины
* @param {Function} callback - Функция обратного вызова
*/
removeListener(callback) {
this.listeners = this.listeners.filter(cb => cb !== callback);
}
/**
* Уведомляет всех слушателей об изменении
* @private
*/
_notify() {
this.listeners.forEach(callback => {
try {
callback(this.items);
} catch (error) {
console.error('Ошибка в слушателе корзины:', error);
}
});
}
/**
* Генерирует ключ для элемента корзины
* @param {string} type - Тип элемента
* @param {number} id - ID элемента
* @param {number} [salesUnitId] - ID единицы продажи
* @returns {string} Ключ корзины
*/
static generateKey(type, id, salesUnitId = null) {
if (salesUnitId) {
return `${type}-${id}-${salesUnitId}`;
}
return `${type}-${id}`;
}
/**
* Добавляет товар в корзину
* @param {string} key - Ключ корзины
* @param {CartItem} item - Элемент для добавления
* @param {boolean} [merge=true] - Объединять ли с существующим
*/
add(key, item, merge = true) {
if (this.items.has(key) && merge) {
const existing = this.items.get(key);
existing.qty = roundQuantity(existing.qty + item.qty);
// Обновляем цену если передана новая
if (item.price !== undefined) {
existing.price = item.price;
}
} else {
this.items.set(key, { ...item });
}
this._notify();
this.scheduleSave();
}
/**
* Обновляет количество элемента
* @param {string} key - Ключ корзины
* @param {number} qty - Новое количество
* @returns {boolean} Успешно ли обновление
*/
updateQuantity(key, qty) {
const item = this.items.get(key);
if (!item) return false;
const roundedQty = roundQuantity(qty);
if (roundedQty <= 0) {
this.remove(key);
return true;
}
item.qty = roundedQty;
this._notify();
this.scheduleSave();
return true;
}
/**
* Удаляет элемент из корзины
* @param {string} key - Ключ корзины
* @returns {boolean} Успешно ли удаление
*/
remove(key) {
const existed = this.items.has(key);
this.items.delete(key);
if (existed) {
this._notify();
this.scheduleSave();
}
return existed;
}
/**
* Очищает корзину
*/
clear() {
this.items.clear();
this._notify();
this.scheduleSave();
}
/**
* Получает элемент корзины
* @param {string} key - Ключ корзины
* @returns {CartItem|undefined} Элемент корзины
*/
get(key) {
return this.items.get(key);
}
/**
* Проверяет наличие элемента
* @param {string} key - Ключ корзины
* @returns {boolean} Есть ли элемент
*/
has(key) {
return this.items.has(key);
}
/**
* Возвращает размер корзины
* @returns {number} Количество элементов
*/
get size() {
return this.items.size;
}
/**
* Проверяет, пуста ли корзина
* @returns {boolean} Пуста ли корзина
*/
get isEmpty() {
return this.items.size === 0;
}
/**
* Вычисляет общую сумму корзины
* @returns {number} Общая сумма
*/
get total() {
let total = 0;
this.items.forEach(item => {
total += item.qty * item.price;
});
return total;
}
/**
* Вычисляет общее количество товаров
* @returns {number} Общее количество
*/
get totalQuantity() {
let count = 0;
this.items.forEach(item => {
count += item.qty;
});
return count;
}
/**
* Проверяет наличие витринных комплектов
* @returns {boolean} Есть ли витринные комплекты
*/
hasShowcaseKits() {
for (const item of this.items.values()) {
if (item.type === 'showcase_kit') return true;
}
return false;
}
/**
* Проверяет наличие обычных комплектов
* @returns {boolean} Есть ли комплекты
*/
hasKits() {
for (const item of this.items.values()) {
if (item.type === 'kit') return true;
}
return false;
}
/**
* Получает все элементы как массив
* @returns {Array<{key: string, item: CartItem}>} Массив элементов
*/
toArray() {
const result = [];
this.items.forEach((item, key) => {
result.push({ key, item });
});
return result;
}
/**
* Конвертирует корзину в обычный объект для JSON
* @returns {Object} Объект корзины
*/
toJSON() {
const obj = {};
this.items.forEach((value, key) => {
obj[key] = value;
});
return obj;
}
/**
* Загружает корзину из объекта
* @param {Object} data - Данные корзины
*/
fromJSON(data) {
this.items.clear();
if (data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
this.items.set(key, value);
});
}
this._notify();
}
/**
* Планирует сохранение корзины на сервер
*/
scheduleSave() {
// Отменяем предыдущий таймер
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
// Устанавливаем новый таймер
this.saveTimeout = setTimeout(() => {
this.saveToServer();
}, CONFIG.TIMEOUTS.DEBOUNCE_CART_SAVE);
}
/**
* Сохраняет корзину на сервер
* @returns {Promise<boolean>} Успешно ли сохранение
*/
async saveToServer() {
try {
const response = await fetch(CONFIG.API.SAVE_CART, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ cart: this.toJSON() })
});
const data = await response.json();
if (!data.success) {
console.error('Ошибка сохранения корзины:', data.error);
return false;
}
return true;
} catch (error) {
console.error('Ошибка при сохранении корзины:', error);
return false;
}
}
/**
* Добавляет витринный комплект с блокировкой на сервере
* @param {Object} item - Данные комплекта
* @param {number} quantity - Количество
* @returns {Promise<{success: boolean, error?: string, data?: Object}>}
*/
async addShowcaseKit(item, quantity = 1) {
try {
const response = await safeFetch(
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
{
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity })
}
);
const data = await response.json();
if (!response.ok || !data.success) {
return {
success: false,
error: data.error || 'Не удалось добавить букет в корзину'
};
}
// Успешно заблокировали - добавляем/обновляем в корзине
const cartKey = Cart.generateKey('showcase_kit', item.id);
const lockedItemIds = data.locked_item_ids || [];
if (this.has(cartKey)) {
const existing = this.get(cartKey);
existing.qty = roundQuantity(existing.qty + lockedItemIds.length);
existing.showcase_item_ids = [...(existing.showcase_item_ids || []), ...lockedItemIds];
} else {
this.add(cartKey, {
id: item.id,
name: item.name,
price: Number(item.price),
qty: lockedItemIds.length,
type: 'showcase_kit',
showcase_item_ids: lockedItemIds,
lock_expires_at: data.lock_expires_at
}, false);
}
return { success: true, data };
} catch (error) {
console.error('Ошибка при добавлении витринного комплекта:', error);
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
}
}
/**
* Удаляет витринный комплект со снятием блокировки
* @param {string} key - Ключ корзины
* @returns {Promise<{success: boolean, error?: string}>}
*/
async removeShowcaseKit(key) {
const item = this.get(key);
if (!item || item.type !== 'showcase_kit') {
this.remove(key);
return { success: true };
}
try {
const body = {};
if (item.showcase_item_ids && item.showcase_item_ids.length > 0) {
body.showcase_item_ids = item.showcase_item_ids;
}
const response = await safeFetch(
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
{
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
const data = await response.json();
if (!response.ok) {
console.error('Ошибка при снятии блокировки:', data.error);
}
// Удаляем из корзины даже при ошибке
this.remove(key);
return { success: true };
} catch (error) {
console.error('Ошибка при снятии блокировки витринного комплекта:', error);
// Удаляем из корзины даже при ошибке
this.remove(key);
return { success: true };
}
}
/**
* Увеличивает количество витринного комплекта
* @param {string} key - Ключ корзины
* @returns {Promise<{success: boolean, error?: string}>}
*/
async increaseShowcaseKitQty(key) {
const item = this.get(key);
if (!item || item.type !== 'showcase_kit') {
return { success: false, error: 'Элемент не найден' };
}
try {
const response = await safeFetch(
CONFIG.API.SHOWCASE_KIT_ADD(item.id),
{
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: 1 })
}
);
const data = await response.json();
if (!response.ok || !data.success) {
return {
success: false,
error: data.error || 'Нет доступных экземпляров этого букета на витрине'
};
}
// Успешно заблокировали - обновляем корзину
const lockedItemIds = data.locked_item_ids || [];
item.qty = roundQuantity(item.qty + lockedItemIds.length);
item.showcase_item_ids = [...(item.showcase_item_ids || []), ...lockedItemIds];
this._notify();
this.scheduleSave();
return { success: true, data };
} catch (error) {
console.error('Ошибка при увеличении количества витринного комплекта:', error);
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
}
}
/**
* Уменьшает количество витринного комплекта
* @param {string} key - Ключ корзины
* @returns {Promise<{success: boolean, error?: string}>}
*/
async decreaseShowcaseKitQty(key) {
const item = this.get(key);
if (!item || item.type !== 'showcase_kit') {
return { success: false, error: 'Элемент не найден' };
}
// Если количество = 1, удаляем полностью
if (item.qty <= 1) {
return this.removeShowcaseKit(key);
}
try {
// Снимаем блокировку с последнего экземпляра
const showcaseItemIds = item.showcase_item_ids || [];
if (showcaseItemIds.length === 0) {
return this.removeShowcaseKit(key);
}
// Берем последний ID из списка
const itemIdToRelease = showcaseItemIds[showcaseItemIds.length - 1];
const response = await safeFetch(
CONFIG.API.SHOWCASE_KIT_REMOVE(item.id),
{
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ showcase_item_ids: [itemIdToRelease] })
}
);
const data = await response.json();
if (!response.ok) {
console.error('Ошибка при снятии блокировки:', data.error);
}
// Обновляем корзину
item.qty = roundQuantity(item.qty - 1);
item.showcase_item_ids = showcaseItemIds.filter(id => id !== itemIdToRelease);
this._notify();
this.scheduleSave();
return { success: true };
} catch (error) {
console.error('Ошибка при уменьшении количества витринного комплекта:', error);
return { success: false, error: 'Ошибка сервера. Попробуйте еще раз.' };
}
}
/**
* Снимает все блокировки витринных комплектов
* @returns {Promise<boolean>}
*/
async releaseAllLocks() {
try {
await safeFetch(
CONFIG.API.SHOWCASE_KIT_RELEASE_ALL,
{
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() }
}
);
return true;
} catch (error) {
console.error('Ошибка сброса блокировок:', error);
return false;
}
}
}