542 lines
16 KiB
JavaScript
542 lines
16 KiB
JavaScript
/**
|
||
* Модуль управления корзиной 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;
|
||
}
|
||
}
|
||
}
|