Рефакторинг POS терминала с использованием ES6 модулей
This commit is contained in:
@@ -17,12 +17,35 @@
|
||||
return (Number(value)).toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Округление количества
|
||||
*/
|
||||
function roundQty(value, decimals = 3) {
|
||||
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование денег
|
||||
*/
|
||||
function fmtMoney(value) {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return '0.00';
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Открытие модалки редактирования
|
||||
* @param {string} cartKey - ключ товара в корзине
|
||||
*/
|
||||
function openModal(cartKey) {
|
||||
const item = window.cart?.get(cartKey);
|
||||
const cart = window.cart;
|
||||
if (!cart) {
|
||||
console.error('CartItemEditor: window.cart not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cart.get(cartKey);
|
||||
if (!item) {
|
||||
console.error('CartItemEditor: Item not found for key:', cartKey);
|
||||
return;
|
||||
@@ -42,33 +65,35 @@
|
||||
const isShowcaseKit = item.type === 'showcase_kit';
|
||||
|
||||
// Заполнение полей
|
||||
document.getElementById('editModalProductName').textContent = item.name || '—';
|
||||
const nameEl = document.getElementById('editModalProductName');
|
||||
const basePriceEl = document.getElementById('editModalBasePrice');
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
|
||||
// Используем formatMoney из terminal.js
|
||||
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
|
||||
document.getElementById('editModalBasePrice').textContent = fmtMoney(basePrice) + ' руб.';
|
||||
|
||||
document.getElementById('editModalPrice').value = roundPrice(basePrice);
|
||||
document.getElementById('editModalQuantity').value = item.qty || 1;
|
||||
if (nameEl) nameEl.textContent = item.name || '—';
|
||||
if (basePriceEl) basePriceEl.textContent = fmtMoney(basePrice) + ' руб.';
|
||||
if (priceInput) priceInput.value = roundPrice(basePrice);
|
||||
if (qtyInput) qtyInput.value = item.qty || 1;
|
||||
|
||||
// Для витринных комплектов блокируем изменение количества
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
const qtyHint = document.getElementById('editModalQtyHint');
|
||||
if (isShowcaseKit) {
|
||||
qtyInput.disabled = true;
|
||||
qtyHint.style.display = 'block';
|
||||
if (qtyInput) qtyInput.disabled = true;
|
||||
if (qtyHint) qtyHint.style.display = 'block';
|
||||
} else {
|
||||
qtyInput.disabled = false;
|
||||
qtyHint.style.display = 'none';
|
||||
if (qtyInput) qtyInput.disabled = false;
|
||||
if (qtyHint) qtyHint.style.display = 'none';
|
||||
}
|
||||
|
||||
// Бейдж единицы измерения
|
||||
const unitBadge = document.getElementById('editModalUnitBadge');
|
||||
if (item.unit_name) {
|
||||
unitBadge.textContent = item.unit_name;
|
||||
unitBadge.style.display = 'inline-block';
|
||||
} else {
|
||||
unitBadge.style.display = 'none';
|
||||
if (unitBadge) {
|
||||
if (item.unit_name) {
|
||||
unitBadge.textContent = item.unit_name;
|
||||
unitBadge.style.display = 'inline-block';
|
||||
} else {
|
||||
unitBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateTotal();
|
||||
@@ -84,18 +109,27 @@
|
||||
* Обновление суммы в модалке
|
||||
*/
|
||||
function updateTotal() {
|
||||
const price = parseFloat(document.getElementById('editModalPrice').value) || 0;
|
||||
const qty = parseFloat(document.getElementById('editModalQuantity').value) || 0;
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
const totalEl = document.getElementById('editModalTotal');
|
||||
const warningEl = document.getElementById('editModalPriceWarning');
|
||||
|
||||
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
|
||||
document.getElementById('editModalTotal').textContent = fmtMoney(price * qty) + ' руб.';
|
||||
if (!priceInput || !qtyInput) return;
|
||||
|
||||
const price = parseFloat(priceInput.value) || 0;
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
|
||||
if (totalEl) {
|
||||
totalEl.textContent = fmtMoney(price * qty) + ' руб.';
|
||||
}
|
||||
|
||||
// Индикатор изменения цены
|
||||
const warning = document.getElementById('editModalPriceWarning');
|
||||
if (Math.abs(price - basePrice) > 0.01) {
|
||||
warning.style.display = 'block';
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
if (warningEl) {
|
||||
if (Math.abs(price - basePrice) > 0.01) {
|
||||
warningEl.style.display = 'block';
|
||||
} else {
|
||||
warningEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,33 +139,43 @@
|
||||
function saveChanges() {
|
||||
if (!editingCartKey) return;
|
||||
|
||||
const newPrice = parseFloat(document.getElementById('editModalPrice').value) || 0;
|
||||
const newQty = parseFloat(document.getElementById('editModalQuantity').value) || 1;
|
||||
const priceInput = document.getElementById('editModalPrice');
|
||||
const qtyInput = document.getElementById('editModalQuantity');
|
||||
|
||||
const item = window.cart?.get(editingCartKey);
|
||||
if (!priceInput) return;
|
||||
|
||||
const newPrice = parseFloat(priceInput.value) || 0;
|
||||
const newQty = parseFloat(qtyInput?.value) || 1;
|
||||
|
||||
const cart = window.cart;
|
||||
if (!cart) {
|
||||
console.error('CartItemEditor: window.cart not found during save!');
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cart.get(editingCartKey);
|
||||
if (item) {
|
||||
// Используем roundQuantity из terminal.js
|
||||
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
|
||||
|
||||
const isShowcaseKit = item.type === 'showcase_kit';
|
||||
|
||||
item.price = newPrice;
|
||||
// Для витринных комплектов не меняем количество
|
||||
if (!isShowcaseKit) {
|
||||
item.qty = rndQty(newQty, 3);
|
||||
item.qty = roundQty(newQty, 3);
|
||||
}
|
||||
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
|
||||
|
||||
window.cart.set(editingCartKey, item);
|
||||
// Обновляем в корзине
|
||||
cart.items.set(editingCartKey, item);
|
||||
|
||||
// Уведомляем слушателей
|
||||
cart._notify();
|
||||
|
||||
// Планируем сохранение
|
||||
cart.scheduleSave();
|
||||
|
||||
// Перерисовка корзины
|
||||
if (typeof renderCart === 'function') {
|
||||
renderCart();
|
||||
}
|
||||
|
||||
// Сохранение на сервере
|
||||
if (typeof saveCartToServer === 'function') {
|
||||
saveCartToServer();
|
||||
if (typeof window.renderCart === 'function') {
|
||||
window.renderCart();
|
||||
}
|
||||
|
||||
console.log('CartItemEditor: Changes saved for', item.name);
|
||||
@@ -170,30 +214,36 @@
|
||||
|
||||
// Обновление суммы при изменении полей
|
||||
priceInput.addEventListener('input', updateTotal);
|
||||
qtyInput.addEventListener('input', updateTotal);
|
||||
if (qtyInput) qtyInput.addEventListener('input', updateTotal);
|
||||
|
||||
// Авто-выделение всего текста при фокусе
|
||||
priceInput.addEventListener('focus', function() {
|
||||
this.select();
|
||||
});
|
||||
qtyInput.addEventListener('focus', function() {
|
||||
this.select();
|
||||
});
|
||||
if (qtyInput) {
|
||||
qtyInput.addEventListener('focus', function() {
|
||||
this.select();
|
||||
});
|
||||
}
|
||||
|
||||
// Кнопка сохранения
|
||||
confirmBtn.addEventListener('click', saveChanges);
|
||||
|
||||
// Сброс при закрытии модалки
|
||||
const modalEl = document.getElementById('editCartItemModal');
|
||||
modalEl.addEventListener('hidden.bs.modal', reset);
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('hidden.bs.modal', reset);
|
||||
}
|
||||
|
||||
// Enter для сохранения
|
||||
priceInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') saveChanges();
|
||||
});
|
||||
qtyInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') saveChanges();
|
||||
});
|
||||
if (qtyInput) {
|
||||
qtyInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') saveChanges();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт функций для использования из terminal.js
|
||||
|
||||
541
myproject/pos/static/pos/js/cart.js
Normal file
541
myproject/pos/static/pos/js/cart.js
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* Модуль управления корзиной 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
852
myproject/pos/static/pos/js/checkout.js
Normal file
852
myproject/pos/static/pos/js/checkout.js
Normal file
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* Модуль оформления заказа (checkout) POS Terminal
|
||||
* Управление оплатой, скидками, промокодами
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
getCsrfToken,
|
||||
showToast,
|
||||
safeFetch,
|
||||
getCombineModeIcon,
|
||||
getCombineModeTitle
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PaymentMethod
|
||||
* @property {string} id - ID метода оплаты
|
||||
* @property {string} name - Название
|
||||
* @property {number} amount - Сумма
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Discount
|
||||
* @property {number} id - ID скидки
|
||||
* @property {string} name - Название
|
||||
* @property {string} type - Тип: percent, fixed
|
||||
* @property {number} value - Значение
|
||||
* @property {string} combine_mode - Режим объединения
|
||||
*/
|
||||
|
||||
export class CheckoutManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||
* @param {import('./customer.js').CustomerManager} options.customerManager - Менеджер клиентов
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.cart = options.cart;
|
||||
this.customerManager = options.customerManager;
|
||||
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||
|
||||
// Состояние checkout
|
||||
this.appliedPromoCode = null;
|
||||
this.appliedManualDiscount = null;
|
||||
this.appliedCustomDiscount = null;
|
||||
this.availableDiscounts = [];
|
||||
this.skipAutoDiscount = false;
|
||||
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: [],
|
||||
itemDiscounts: [],
|
||||
totalDiscount: 0,
|
||||
excludedBy: null
|
||||
};
|
||||
|
||||
// Оплата
|
||||
this.paymentMode = 'single'; // 'single' | 'mixed'
|
||||
this.paymentMethods = [];
|
||||
this.walletPaymentAmount = 0;
|
||||
|
||||
// Модальное окно
|
||||
this.modalInstance = null;
|
||||
|
||||
// Callback'и
|
||||
this.onCheckoutComplete = options.onCheckoutComplete || null;
|
||||
this.onCheckoutError = options.onCheckoutError || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует checkout модальное окно
|
||||
*/
|
||||
init() {
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно checkout
|
||||
*/
|
||||
open() {
|
||||
if (this.cart.isEmpty) {
|
||||
showToast('error', 'Корзина пуста. Добавьте товары перед продажей.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderCheckoutModal();
|
||||
|
||||
const modalEl = document.getElementById('checkoutModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Загружаем доступные скидки
|
||||
this.loadAvailableDiscounts();
|
||||
|
||||
// Рассчитываем скидки
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает модальное окно checkout
|
||||
*/
|
||||
close() {
|
||||
if (this.modalInstance) {
|
||||
this.modalInstance.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит содержимое модального окна checkout
|
||||
*/
|
||||
renderCheckoutModal() {
|
||||
this.renderOrderItems();
|
||||
this.renderPaymentWidget();
|
||||
this.updateFinalPrice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит список товаров в заказе
|
||||
*/
|
||||
renderOrderItems() {
|
||||
const container = document.getElementById('checkoutItems');
|
||||
if (!container) return;
|
||||
|
||||
let html = '';
|
||||
|
||||
this.cart.toArray().forEach(({ key, item }) => {
|
||||
const itemTotal = item.price * item.qty;
|
||||
const typeIcon = (item.type === 'kit' || item.type === 'showcase_kit')
|
||||
? '<i class="bi bi-box-seam text-info"></i> '
|
||||
: '';
|
||||
|
||||
html += `
|
||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom">
|
||||
<div class="flex-grow-1">
|
||||
<div class="small">${typeIcon}${item.name}</div>
|
||||
<div class="text-muted" style="font-size: 0.8rem;">
|
||||
${formatMoney(item.price)} × ${roundQuantity(item.qty)}
|
||||
${item.unit_name ? ' ' + item.unit_name : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fw-semibold">${formatMoney(itemTotal)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<p class="text-muted text-center py-2">Корзина пуста</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет оплаты
|
||||
*/
|
||||
renderPaymentWidget() {
|
||||
const container = document.getElementById('paymentWidgetContainer');
|
||||
if (!container) return;
|
||||
|
||||
const total = this.getFinalTotal();
|
||||
const customer = this.customerManager.getCurrentCustomer();
|
||||
const walletBalance = customer?.wallet_balance || 0;
|
||||
|
||||
if (this.paymentMode === 'single') {
|
||||
container.innerHTML = this.renderSinglePaymentWidget(total, walletBalance);
|
||||
} else {
|
||||
container.innerHTML = this.renderMixedPaymentWidget(total, walletBalance);
|
||||
}
|
||||
|
||||
this.initPaymentEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет одиночной оплаты
|
||||
*/
|
||||
renderSinglePaymentWidget(total, walletBalance) {
|
||||
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||
|
||||
return `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small mb-1">Способ оплаты</label>
|
||||
<select class="form-select form-select-sm" id="singlePaymentMethod">
|
||||
<option value="cash">Наличные</option>
|
||||
<option value="card">Карта</option>
|
||||
<option value="bank_transfer">Банковский перевод</option>
|
||||
${!isSystemCustomer && walletBalance > 0 ? `<option value="wallet">Кошелёк клиента (${formatMoney(walletBalance)} руб.)</option>` : ''}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="cashChangeBlock" style="display: none;">
|
||||
<label class="form-label small mb-1">Получено наличных</label>
|
||||
<input type="number" class="form-control form-control-sm" id="cashReceived"
|
||||
placeholder="0.00" step="0.01" min="0">
|
||||
<div class="mt-1 small">
|
||||
<span class="text-muted">Сдача:</span>
|
||||
<span class="fw-semibold" id="cashChange">0.00 руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит виджет смешанной оплаты
|
||||
*/
|
||||
renderMixedPaymentWidget(total, walletBalance) {
|
||||
const isSystemCustomer = this.customerManager.isSystemCustomer();
|
||||
let html = `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small mb-1">Распределение оплаты</label>
|
||||
<div class="border rounded p-2">
|
||||
`;
|
||||
|
||||
// Наличные
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Наличные</label>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="cash" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Карта
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Карта</label>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="card" placeholder="0.00" step="0.01" min="0" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Кошелёк (если не системный клиент)
|
||||
if (!isSystemCustomer && walletBalance > 0) {
|
||||
html += `
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1">
|
||||
<label class="small mb-0">Кошелёк</label>
|
||||
<small class="text-muted d-block">Доступно: ${formatMoney(walletBalance)}</small>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm mixed-payment-input"
|
||||
data-method="wallet" placeholder="0.00" step="0.01" min="0" max="${walletBalance}" style="width: 100px;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
<div class="mt-2 d-flex justify-content-between small">
|
||||
<span class="text-muted">Введено:</span>
|
||||
<span id="mixedPaymentTotal">0.00 руб.</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small">
|
||||
<span class="text-muted">Осталось:</span>
|
||||
<span id="mixedPaymentRemaining" class="fw-semibold">${formatMoney(total)} руб.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий оплаты
|
||||
*/
|
||||
initPaymentEventListeners() {
|
||||
// Переключение режима оплаты
|
||||
document.getElementById('singlePaymentMode')?.addEventListener('click', () => {
|
||||
this.paymentMode = 'single';
|
||||
document.getElementById('singlePaymentMode')?.classList.add('active');
|
||||
document.getElementById('mixedPaymentMode')?.classList.remove('active');
|
||||
this.renderPaymentWidget();
|
||||
});
|
||||
|
||||
document.getElementById('mixedPaymentMode')?.addEventListener('click', () => {
|
||||
this.paymentMode = 'mixed';
|
||||
document.getElementById('mixedPaymentMode')?.classList.add('active');
|
||||
document.getElementById('singlePaymentMode')?.classList.remove('active');
|
||||
this.renderPaymentWidget();
|
||||
});
|
||||
|
||||
// Показ/скрытие блока сдачи
|
||||
document.getElementById('singlePaymentMethod')?.addEventListener('change', (e) => {
|
||||
const cashBlock = document.getElementById('cashChangeBlock');
|
||||
if (cashBlock) {
|
||||
cashBlock.style.display = e.target.value === 'cash' ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Расчёт сдачи
|
||||
document.getElementById('cashReceived')?.addEventListener('input', (e) => {
|
||||
const received = parseFloat(e.target.value) || 0;
|
||||
const total = this.getFinalTotal();
|
||||
const change = Math.max(0, received - total);
|
||||
document.getElementById('cashChange').textContent = formatMoney(change) + ' руб.';
|
||||
});
|
||||
|
||||
// Смешанная оплата - пересчёт сумм
|
||||
document.querySelectorAll('.mixed-payment-input')?.forEach(input => {
|
||||
input.addEventListener('input', () => this.updateMixedPaymentTotals());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет суммы при смешанной оплате
|
||||
*/
|
||||
updateMixedPaymentTotals() {
|
||||
const total = this.getFinalTotal();
|
||||
let entered = 0;
|
||||
|
||||
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||
entered += parseFloat(input.value) || 0;
|
||||
});
|
||||
|
||||
const remaining = Math.max(0, total - entered);
|
||||
|
||||
const totalEl = document.getElementById('mixedPaymentTotal');
|
||||
const remainingEl = document.getElementById('mixedPaymentRemaining');
|
||||
|
||||
if (totalEl) totalEl.textContent = formatMoney(entered) + ' руб.';
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = formatMoney(remaining) + ' руб.';
|
||||
remainingEl.className = remaining > 0 ? 'fw-semibold text-danger' : 'fw-semibold text-success';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий
|
||||
*/
|
||||
initEventListeners() {
|
||||
// Промокод
|
||||
document.getElementById('applyPromoBtn')?.addEventListener('click', () => this.applyPromoCode());
|
||||
document.getElementById('removePromoBtn')?.addEventListener('click', () => this.removePromoCode());
|
||||
document.getElementById('promoCodeInput')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.applyPromoCode();
|
||||
});
|
||||
|
||||
// Произвольная скидка
|
||||
document.getElementById('applyCustomDiscountBtn')?.addEventListener('click', () => this.applyCustomDiscount());
|
||||
document.getElementById('removeCustomDiscountBtn')?.addEventListener('click', () => this.removeCustomDiscount());
|
||||
document.getElementById('customDiscountInput')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.applyCustomDiscount();
|
||||
});
|
||||
|
||||
// Отмена автоматической скидки
|
||||
document.getElementById('skipAutoDiscountBtn')?.addEventListener('click', () => this.skipAutoDiscounts());
|
||||
|
||||
// Удаление ручной скидки
|
||||
document.getElementById('removeManualDiscountBtn')?.addEventListener('click', () => this.removeManualDiscount());
|
||||
|
||||
// Подтверждение продажи
|
||||
document.getElementById('confirmCheckoutBtn')?.addEventListener('click', () => this.confirmCheckout());
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает доступные скидки
|
||||
*/
|
||||
async loadAvailableDiscounts() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.DISCOUNTS_AVAILABLE);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.availableDiscounts = data.discounts || [];
|
||||
this.renderDiscountsDropdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки скидок:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит dropdown со скидками
|
||||
*/
|
||||
renderDiscountsDropdown() {
|
||||
const list = document.getElementById('discountsDropdownList');
|
||||
if (!list) return;
|
||||
|
||||
if (this.availableDiscounts.length === 0) {
|
||||
list.innerHTML = '<li><span class="dropdown-item-text small text-muted">Нет доступных скидок</span></li>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
this.availableDiscounts.forEach(discount => {
|
||||
const icon = getCombineModeIcon(discount.combine_mode);
|
||||
const valueText = discount.type === 'percent'
|
||||
? `${discount.value}%`
|
||||
: `${formatMoney(discount.value)} руб.`;
|
||||
|
||||
html += `
|
||||
<li>
|
||||
<button class="dropdown-item small" type="button" data-discount-id="${discount.id}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>${discount.name}</span>
|
||||
<span class="badge bg-primary ms-2">${valueText}</span>
|
||||
</div>
|
||||
<small class="text-muted">${icon} ${getCombineModeTitle(discount.combine_mode)}</small>
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Добавляем обработчики
|
||||
list.querySelectorAll('[data-discount-id]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const discountId = parseInt(e.currentTarget.dataset.discountId);
|
||||
this.applyManualDiscount(discountId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает скидки
|
||||
*/
|
||||
async calculateDiscounts() {
|
||||
try {
|
||||
const items = this.cart.toArray().map(({ item }) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
price: item.price,
|
||||
quantity: item.qty
|
||||
}));
|
||||
|
||||
const requestBody = {
|
||||
items: items,
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||
skip_auto: this.skipAutoDiscount,
|
||||
manual_discount_id: this.appliedManualDiscount?.id || null,
|
||||
custom_discount: this.appliedCustomDiscount,
|
||||
promo_code: this.appliedPromoCode
|
||||
};
|
||||
|
||||
const response = await safeFetch(CONFIG.API.DISCOUNTS_CALCULATE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: data.order_discounts || [],
|
||||
itemDiscounts: data.item_discounts || [],
|
||||
totalDiscount: data.total_discount || 0,
|
||||
excludedBy: data.excluded_by || null
|
||||
};
|
||||
this.renderDiscounts();
|
||||
this.updateFinalPrice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка расчёта скидок:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит информацию о скидках
|
||||
*/
|
||||
renderDiscounts() {
|
||||
// Автоматические скидки
|
||||
const autoContainer = document.getElementById('autoDiscountsContainer');
|
||||
const autoList = document.getElementById('autoDiscountsList');
|
||||
const skipBtn = document.getElementById('skipAutoDiscountBtn');
|
||||
|
||||
if (this.cartDiscounts.orderDiscounts.length > 0 && !this.skipAutoDiscount) {
|
||||
if (autoContainer) autoContainer.style.display = 'block';
|
||||
if (skipBtn) skipBtn.style.display = 'block';
|
||||
|
||||
if (autoList) {
|
||||
autoList.innerHTML = this.cartDiscounts.orderDiscounts.map(d => {
|
||||
const valueText = d.type === 'percent' ? `${d.value}%` : `${formatMoney(d.value)} руб.`;
|
||||
return `<div>${d.name}: ${valueText}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
if (autoContainer) autoContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Ручная скидка
|
||||
const manualContainer = document.getElementById('manualDiscountContainer');
|
||||
if (this.appliedManualDiscount && manualContainer) {
|
||||
manualContainer.style.display = 'block';
|
||||
document.getElementById('manualDiscountName').textContent = this.appliedManualDiscount.name;
|
||||
const valueText = this.appliedManualDiscount.type === 'percent'
|
||||
? `${this.appliedManualDiscount.value}%`
|
||||
: `${formatMoney(this.appliedManualDiscount.value)} руб.`;
|
||||
document.getElementById('manualDiscountAmount').textContent = valueText;
|
||||
} else if (manualContainer) {
|
||||
manualContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Промокод
|
||||
const promoSuccess = document.getElementById('promoCodeSuccess');
|
||||
const removePromoBtn = document.getElementById('removePromoBtn');
|
||||
const applyPromoBtn = document.getElementById('applyPromoBtn');
|
||||
|
||||
if (this.appliedPromoCode) {
|
||||
if (promoSuccess) {
|
||||
promoSuccess.style.display = 'block';
|
||||
promoSuccess.textContent = `Промокод "${this.appliedPromoCode.code}" применён: -${formatMoney(this.appliedPromoCode.discount)} руб.`;
|
||||
}
|
||||
if (removePromoBtn) removePromoBtn.style.display = 'block';
|
||||
if (applyPromoBtn) applyPromoBtn.style.display = 'none';
|
||||
} else {
|
||||
if (promoSuccess) promoSuccess.style.display = 'none';
|
||||
if (removePromoBtn) removePromoBtn.style.display = 'none';
|
||||
if (applyPromoBtn) applyPromoBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Произвольная скидка
|
||||
const removeCustomBtn = document.getElementById('removeCustomDiscountBtn');
|
||||
const applyCustomBtn = document.getElementById('applyCustomDiscountBtn');
|
||||
|
||||
if (this.appliedCustomDiscount) {
|
||||
if (removeCustomBtn) removeCustomBtn.style.display = 'block';
|
||||
if (applyCustomBtn) applyCustomBtn.style.display = 'none';
|
||||
} else {
|
||||
if (removeCustomBtn) removeCustomBtn.style.display = 'none';
|
||||
if (applyCustomBtn) applyCustomBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Итоговая информация
|
||||
const summary = document.getElementById('discountsSummary');
|
||||
if (this.cartDiscounts.totalDiscount > 0 && summary) {
|
||||
summary.style.display = 'block';
|
||||
document.getElementById('discountsSubtotal').textContent = formatMoney(this.cart.total) + ' руб.';
|
||||
document.getElementById('discountsTotalDiscount').textContent = '-' + formatMoney(this.cartDiscounts.totalDiscount) + ' руб.';
|
||||
} else if (summary) {
|
||||
summary.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет промокод
|
||||
*/
|
||||
async applyPromoCode() {
|
||||
const input = document.getElementById('promoCodeInput');
|
||||
const code = input?.value.trim().toUpperCase();
|
||||
|
||||
if (!code) {
|
||||
this.showPromoError('Введите промокод');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.VALIDATE_PROMO, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
cart_total: this.cart.total,
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.appliedPromoCode = {
|
||||
code: code,
|
||||
discount: data.discount_amount,
|
||||
promoCodeId: data.promo_code_id
|
||||
};
|
||||
this.hidePromoError();
|
||||
this.calculateDiscounts();
|
||||
} else {
|
||||
this.showPromoError(data.error || 'Недействительный промокод');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showPromoError('Ошибка при проверке промокода');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет промокод
|
||||
*/
|
||||
removePromoCode() {
|
||||
this.appliedPromoCode = null;
|
||||
const input = document.getElementById('promoCodeInput');
|
||||
if (input) input.value = '';
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает ошибку промокода
|
||||
*/
|
||||
showPromoError(message) {
|
||||
const errorEl = document.getElementById('promoCodeError');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает ошибку промокода
|
||||
*/
|
||||
hidePromoError() {
|
||||
const errorEl = document.getElementById('promoCodeError');
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет ручную скидку
|
||||
*/
|
||||
applyManualDiscount(discountId) {
|
||||
const discount = this.availableDiscounts.find(d => d.id === discountId);
|
||||
if (discount) {
|
||||
this.appliedManualDiscount = discount;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет ручную скидку
|
||||
*/
|
||||
removeManualDiscount() {
|
||||
this.appliedManualDiscount = null;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет произвольную скидку
|
||||
*/
|
||||
applyCustomDiscount() {
|
||||
const input = document.getElementById('customDiscountInput');
|
||||
const isPercentCheckbox = document.getElementById('customDiscountIsPercent');
|
||||
|
||||
const value = parseFloat(input?.value);
|
||||
if (!value || value <= 0) {
|
||||
this.showCustomDiscountError('Введите положительное значение');
|
||||
return;
|
||||
}
|
||||
|
||||
const isPercent = isPercentCheckbox?.checked;
|
||||
|
||||
if (isPercent && value > 100) {
|
||||
this.showCustomDiscountError('Процент не может превышать 100%');
|
||||
return;
|
||||
}
|
||||
|
||||
this.appliedCustomDiscount = {
|
||||
type: isPercent ? 'percent' : 'fixed',
|
||||
value: value
|
||||
};
|
||||
|
||||
this.hideCustomDiscountError();
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет произвольную скидку
|
||||
*/
|
||||
removeCustomDiscount() {
|
||||
this.appliedCustomDiscount = null;
|
||||
const input = document.getElementById('customDiscountInput');
|
||||
if (input) input.value = '';
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает ошибку произвольной скидки
|
||||
*/
|
||||
showCustomDiscountError(message) {
|
||||
const errorEl = document.getElementById('customDiscountError');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает ошибку произвольной скидки
|
||||
*/
|
||||
hideCustomDiscountError() {
|
||||
const errorEl = document.getElementById('customDiscountError');
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропускает автоматические скидки
|
||||
*/
|
||||
skipAutoDiscounts() {
|
||||
this.skipAutoDiscount = true;
|
||||
this.calculateDiscounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает итоговую сумму к оплате
|
||||
*/
|
||||
getFinalTotal() {
|
||||
return Math.max(0, this.cart.total - this.cartDiscounts.totalDiscount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет отображение итоговой цены
|
||||
*/
|
||||
updateFinalPrice() {
|
||||
const finalPriceEl = document.getElementById('checkoutFinalPrice');
|
||||
if (finalPriceEl) {
|
||||
finalPriceEl.textContent = formatMoney(this.getFinalTotal()) + ' руб.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает данные об оплате
|
||||
*/
|
||||
getPaymentData() {
|
||||
if (this.paymentMode === 'single') {
|
||||
const method = document.getElementById('singlePaymentMethod')?.value || 'cash';
|
||||
const amount = this.getFinalTotal();
|
||||
|
||||
const payments = [{ payment_method: method, amount }];
|
||||
|
||||
// Если оплата наличными, добавляем информацию о сдаче
|
||||
if (method === 'cash') {
|
||||
const received = parseFloat(document.getElementById('cashReceived')?.value) || amount;
|
||||
return {
|
||||
payments,
|
||||
cash_received: received,
|
||||
change: Math.max(0, received - amount)
|
||||
};
|
||||
}
|
||||
|
||||
return { payments };
|
||||
} else {
|
||||
const payments = [];
|
||||
document.querySelectorAll('.mixed-payment-input').forEach(input => {
|
||||
const amount = parseFloat(input.value) || 0;
|
||||
if (amount > 0) {
|
||||
payments.push({
|
||||
payment_method: input.dataset.method,
|
||||
amount: amount
|
||||
});
|
||||
}
|
||||
});
|
||||
return { payments };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает продажу
|
||||
*/
|
||||
async confirmCheckout() {
|
||||
const paymentData = this.getPaymentData();
|
||||
const total = this.getFinalTotal();
|
||||
|
||||
// Проверяем сумму оплаты
|
||||
const totalPaid = paymentData.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
if (this.paymentMode === 'mixed' && Math.abs(totalPaid - total) > 0.01) {
|
||||
showToast('error', `Сумма оплаты (${formatMoney(totalPaid)}) не совпадает с итогом (${formatMoney(total)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем данные заказа
|
||||
const orderData = {
|
||||
items: this.cart.toArray().map(({ item }) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
quantity: item.qty,
|
||||
price: item.price,
|
||||
sales_unit_id: item.sales_unit_id || null
|
||||
})),
|
||||
customer_id: this.customerManager.getCurrentCustomer()?.id,
|
||||
warehouse_id: this.currentWarehouse?.id,
|
||||
payments: paymentData.payments,
|
||||
discounts: {
|
||||
order_discounts: this.cartDiscounts.orderDiscounts,
|
||||
total_discount: this.cartDiscounts.totalDiscount,
|
||||
promo_code: this.appliedPromoCode,
|
||||
manual_discount: this.appliedManualDiscount,
|
||||
custom_discount: this.appliedCustomDiscount
|
||||
},
|
||||
note: document.getElementById('orderNote')?.value || '',
|
||||
...paymentData
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.CHECKOUT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('success', `Продажа оформлена! Заказ #${data.order_id}`);
|
||||
|
||||
// Очищаем корзину
|
||||
this.cart.clear();
|
||||
|
||||
// Закрываем модалку
|
||||
this.close();
|
||||
|
||||
// Вызываем callback
|
||||
if (this.onCheckoutComplete) {
|
||||
this.onCheckoutComplete(data);
|
||||
}
|
||||
|
||||
// Переход к чеку или обновление страницы
|
||||
if (data.receipt_url) {
|
||||
window.open(data.receipt_url, '_blank');
|
||||
}
|
||||
} else {
|
||||
showToast('error', data.error || 'Ошибка при оформлении продажи');
|
||||
if (this.onCheckoutError) {
|
||||
this.onCheckoutError(data.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при оформлении продажи:', error);
|
||||
showToast('error', 'Ошибка сети при оформлении продажи');
|
||||
if (this.onCheckoutError) {
|
||||
this.onCheckoutError('Network error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает состояние checkout
|
||||
*/
|
||||
reset() {
|
||||
this.appliedPromoCode = null;
|
||||
this.appliedManualDiscount = null;
|
||||
this.appliedCustomDiscount = null;
|
||||
this.skipAutoDiscount = false;
|
||||
this.paymentMode = 'single';
|
||||
this.walletPaymentAmount = 0;
|
||||
this.cartDiscounts = {
|
||||
orderDiscounts: [],
|
||||
itemDiscounts: [],
|
||||
totalDiscount: 0,
|
||||
excludedBy: null
|
||||
};
|
||||
}
|
||||
}
|
||||
85
myproject/pos/static/pos/js/config.js
Normal file
85
myproject/pos/static/pos/js/config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Конфигурация POS Terminal
|
||||
* Централизованное хранение констант и настроек
|
||||
*/
|
||||
|
||||
const CONFIG = {
|
||||
// API Endpoints
|
||||
API: {
|
||||
SAVE_CART: '/pos/api/save-cart/',
|
||||
SET_CUSTOMER: '/pos/api/set-customer/',
|
||||
ITEMS: '/pos/api/items/',
|
||||
SHOWCASE_KITS: '/pos/api/showcase-kits/',
|
||||
SHOWCASE_KIT_ADD: (id) => `/pos/api/showcase-kits/${id}/add-to-cart/`,
|
||||
SHOWCASE_KIT_REMOVE: (id) => `/pos/api/showcase-kits/${id}/remove-from-cart/`,
|
||||
SHOWCASE_KIT_RELEASE_ALL: '/pos/api/showcase-kits/release-all-my-locks/',
|
||||
PRODUCT_KIT: (id) => `/pos/api/product-kits/${id}/`,
|
||||
PRODUCT_KIT_UPDATE: (id) => `/pos/api/product-kits/${id}/update/`,
|
||||
PRODUCT_KIT_DISASSEMBLE: (id) => `/pos/api/product-kits/${id}/disassemble/`,
|
||||
PRODUCT_KIT_WRITE_OFF: (id) => `/pos/api/product-kits/${id}/write-off/`,
|
||||
CREATE_TEMP_KIT: '/pos/api/create-temp-kit/',
|
||||
GET_SHOWCASES: '/pos/api/get-showcases/',
|
||||
SALES_UNITS: (productId) => `/products/api/products/${productId}/sales-units/`,
|
||||
CHECKOUT: '/pos/api/checkout/',
|
||||
SET_WAREHOUSE: (id) => `/pos/api/set-warehouse/${id}/`,
|
||||
DISCOUNTS_CALCULATE: '/pos/api/discounts/calculate/',
|
||||
DISCOUNTS_AVAILABLE: '/pos/api/discounts/available/',
|
||||
VALIDATE_PROMO: '/pos/api/discounts/validate-promo/',
|
||||
CREATE_DEFERRED_ORDER: '/orders/api/create-from-pos/',
|
||||
CUSTOMER_SEARCH: '/customers/api/search/',
|
||||
CUSTOMER_CREATE: '/customers/api/create/',
|
||||
},
|
||||
|
||||
// Таймауты (в миллисекундах)
|
||||
TIMEOUTS: {
|
||||
DEBOUNCE_SEARCH: 300,
|
||||
DEBOUNCE_CART_SAVE: 500,
|
||||
TOAST_DELAY: 5000,
|
||||
SELECT2_DELAY: 300,
|
||||
},
|
||||
|
||||
// Пагинация
|
||||
PAGINATION: {
|
||||
PAGE_SIZE: 60,
|
||||
ROOT_MARGIN: '200px',
|
||||
},
|
||||
|
||||
// Форматирование чисел
|
||||
NUMBERS: {
|
||||
DECIMALS_QUANTITY: 3,
|
||||
DECIMALS_MONEY: 2,
|
||||
QUANTITY_STEP: 0.001,
|
||||
},
|
||||
|
||||
// Поиск
|
||||
SEARCH: {
|
||||
MIN_LENGTH: 3,
|
||||
},
|
||||
|
||||
// CSS классы
|
||||
CSS: {
|
||||
STOCK_GOOD: '#28a745',
|
||||
STOCK_LOW: '#ffc107',
|
||||
STOCK_NONE: '#dc3545',
|
||||
SHOWCASE_BG: '#fff3cd',
|
||||
SHOWCASE_BORDER: '#ffc107',
|
||||
},
|
||||
|
||||
// Мобильные устройства
|
||||
MOBILE: {
|
||||
SCREEN_WIDTH_THRESHOLD: 768,
|
||||
USER_AGENTS: ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Windows Phone'],
|
||||
},
|
||||
};
|
||||
|
||||
// Заморозить конфигурацию для предотвращения изменений
|
||||
Object.freeze(CONFIG);
|
||||
Object.freeze(CONFIG.API);
|
||||
Object.freeze(CONFIG.TIMEOUTS);
|
||||
Object.freeze(CONFIG.PAGINATION);
|
||||
Object.freeze(CONFIG.NUMBERS);
|
||||
Object.freeze(CONFIG.SEARCH);
|
||||
Object.freeze(CONFIG.CSS);
|
||||
Object.freeze(CONFIG.MOBILE);
|
||||
|
||||
export default CONFIG;
|
||||
290
myproject/pos/static/pos/js/customer.js
Normal file
290
myproject/pos/static/pos/js/customer.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Модуль управления клиентами POS Terminal
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import { getCsrfToken, safeFetch, showToast } from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Customer
|
||||
* @property {number} id - ID клиента
|
||||
* @property {string} name - Имя клиента
|
||||
* @property {number} [wallet_balance] - Баланс кошелька
|
||||
* @property {string} [phone] - Телефон
|
||||
* @property {string} [email] - Email
|
||||
*/
|
||||
|
||||
export class CustomerManager {
|
||||
/**
|
||||
* @param {Customer} systemCustomer - Системный клиент по умолчанию
|
||||
*/
|
||||
constructor(systemCustomer) {
|
||||
/** @type {Customer} */
|
||||
this.systemCustomer = systemCustomer;
|
||||
|
||||
/** @type {Customer} */
|
||||
this.selectedCustomer = systemCustomer;
|
||||
|
||||
/** @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.selectedCustomer);
|
||||
} catch (error) {
|
||||
console.error('Ошибка в слушателе клиента:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, выбран ли системный клиент
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSystemCustomer() {
|
||||
return Number(this.selectedCustomer.id) === Number(this.systemCustomer.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущего клиента
|
||||
* @returns {Customer}
|
||||
*/
|
||||
getCurrentCustomer() {
|
||||
return this.selectedCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает отображаемое имя клиента
|
||||
* @returns {string}
|
||||
*/
|
||||
getDisplayName() {
|
||||
return this.isSystemCustomer() ? 'Системный клиент' : this.selectedCustomer.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает клиента
|
||||
* @param {Customer} customer - Данные клиента
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async selectCustomer(customer) {
|
||||
this.selectedCustomer = customer;
|
||||
this._notify();
|
||||
|
||||
// Сохраняем выбор на сервере
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`${CONFIG.API.SET_CUSTOMER}${customer.id}/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Ошибка сохранения клиента:', data.error);
|
||||
showToast('error', 'Ошибка сохранения выбора клиента');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Обновляем баланс из ответа сервера
|
||||
this.selectedCustomer.wallet_balance = data.wallet_balance || 0;
|
||||
this._notify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении клиента:', error);
|
||||
showToast('error', 'Ошибка сети при сохранении клиента');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает на системного клиента
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async resetToSystem() {
|
||||
return this.selectCustomer(this.systemCustomer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт нового клиента
|
||||
* @param {Object} data - Данные клиента
|
||||
* @param {string} data.name - Имя
|
||||
* @param {string} [data.phone] - Телефон
|
||||
* @param {string} [data.email] - Email
|
||||
* @returns {Promise<{success: boolean, customer?: Customer, error?: string}>}
|
||||
*/
|
||||
async createCustomer(data) {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.CUSTOMER_CREATE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
phone: data.phone || null,
|
||||
email: data.email || null
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const customer = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
wallet_balance: result.wallet_balance || 0
|
||||
};
|
||||
|
||||
// Автоматически выбираем созданного клиента
|
||||
await this.selectCustomer(customer);
|
||||
|
||||
showToast('success', `Клиент "${result.name}" успешно создан!`);
|
||||
return { success: true, customer };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Ошибка при создании клиента' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
return { success: false, error: 'Ошибка сети при создании клиента' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует Select2 для поиска клиента
|
||||
* @param {string} selector - CSS селектор
|
||||
* @param {Object} options - Дополнительные опции
|
||||
*/
|
||||
initSelect2(selector, options = {}) {
|
||||
const $searchInput = $(selector);
|
||||
const modalId = options.modalId || '#selectCustomerModal';
|
||||
|
||||
$searchInput.select2({
|
||||
theme: 'bootstrap-5',
|
||||
dropdownParent: $(modalId),
|
||||
placeholder: 'Начните вводить имя, телефон или email (минимум 3 символа)',
|
||||
minimumInputLength: 3,
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: CONFIG.API.CUSTOMER_SEARCH,
|
||||
dataType: 'json',
|
||||
delay: CONFIG.TIMEOUTS.SELECT2_DELAY,
|
||||
data: function (params) {
|
||||
return { q: params.term };
|
||||
},
|
||||
processResults: function (data) {
|
||||
return { results: data.results };
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
templateResult: this._formatCustomerOption,
|
||||
templateSelection: this._formatCustomerSelection
|
||||
});
|
||||
|
||||
// Обработка выбора
|
||||
$searchInput.on('select2:select', async (e) => {
|
||||
const data = e.params.data;
|
||||
|
||||
// Проверяем это не опция "Создать нового клиента"
|
||||
if (data.id === 'create_new') {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||
modal.hide();
|
||||
|
||||
if (options.onCreateNew) {
|
||||
options.onCreateNew(data.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Выбираем клиента
|
||||
await this.selectCustomer({
|
||||
id: parseInt(data.id),
|
||||
name: data.name,
|
||||
wallet_balance: data.wallet_balance || 0
|
||||
});
|
||||
|
||||
// Закрываем модалку
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
|
||||
modal.hide();
|
||||
|
||||
// Очищаем Select2
|
||||
$searchInput.val(null).trigger('change');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует опцию клиента в выпадающем списке
|
||||
* @private
|
||||
*/
|
||||
_formatCustomerOption(customer) {
|
||||
if (customer.loading) {
|
||||
return customer.text;
|
||||
}
|
||||
|
||||
// Если это опция "Создать нового клиента"
|
||||
if (customer.id === 'create_new') {
|
||||
return $('<span><i class="bi bi-person-plus"></i> ' + customer.text + '</span>');
|
||||
}
|
||||
|
||||
// Формируем текст в одну строку
|
||||
const parts = [];
|
||||
|
||||
// Имя
|
||||
const name = customer.name || customer.text;
|
||||
parts.push('<span class="fw-bold">' + $('<div>').text(name).html() + '</span>');
|
||||
|
||||
// Телефон и Email
|
||||
const contactInfo = [];
|
||||
if (customer.phone) {
|
||||
contactInfo.push($('<div>').text(customer.phone).html());
|
||||
}
|
||||
if (customer.email) {
|
||||
contactInfo.push($('<div>').text(customer.email).html());
|
||||
}
|
||||
|
||||
if (contactInfo.length > 0) {
|
||||
parts.push('<span class="text-muted small"> (' + contactInfo.join(', ') + ')</span>');
|
||||
}
|
||||
|
||||
return $('<span>' + parts.join('') + '</span>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует выбранного клиента
|
||||
* @private
|
||||
*/
|
||||
_formatCustomerSelection(customer) {
|
||||
return customer.name || customer.text;
|
||||
}
|
||||
}
|
||||
36
myproject/pos/static/pos/js/modules.js
Normal file
36
myproject/pos/static/pos/js/modules.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Точка входа для модульной архитектуры POS Terminal
|
||||
* Загружает все модули и инициализирует приложение
|
||||
*/
|
||||
|
||||
// Импорт конфигурации
|
||||
import CONFIG from './config.js';
|
||||
|
||||
// Импорт утилит
|
||||
import * as utils from './utils.js';
|
||||
|
||||
// Импорт менеджеров
|
||||
import { Cart } from './cart.js';
|
||||
import { CustomerManager } from './customer.js';
|
||||
import { ProductManager } from './products.js';
|
||||
|
||||
// Экспорт всего для глобального доступа
|
||||
export {
|
||||
CONFIG,
|
||||
utils,
|
||||
Cart,
|
||||
CustomerManager,
|
||||
ProductManager
|
||||
};
|
||||
|
||||
// Инициализация при загрузке
|
||||
export function initPOS() {
|
||||
// Создаем глобальные ссылки для обратной совместимости
|
||||
window.POS = {
|
||||
CONFIG,
|
||||
utils,
|
||||
Cart,
|
||||
CustomerManager,
|
||||
ProductManager
|
||||
};
|
||||
}
|
||||
544
myproject/pos/static/pos/js/products.js
Normal file
544
myproject/pos/static/pos/js/products.js
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Модуль управления товарами и витриной POS Terminal
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
formatDaysAgo,
|
||||
safeFetch,
|
||||
escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Product
|
||||
* @property {number} id - ID товара
|
||||
* @property {string} name - Название
|
||||
* @property {string} [sku] - Артикул
|
||||
* @property {number} price - Цена
|
||||
* @property {number} [price_in_unit] - Цена в единице продажи
|
||||
* @property {number} [available_qty] - Доступное количество
|
||||
* @property {number} [reserved_qty] - Зарезервированное количество
|
||||
* @property {boolean} has_sales_units - Есть ли единицы продажи
|
||||
* @property {number} sales_units_count - Количество единиц продажи
|
||||
* @property {Object} [default_sales_unit] - Единица продажи по умолчанию
|
||||
* @property {string} [image] - URL изображения
|
||||
* @property {string} type - Тип: 'product', 'kit', 'showcase_kit'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShowcaseKit
|
||||
* @property {number} id - ID комплекта
|
||||
* @property {string} name - Название
|
||||
* @property {string} [sku] - Артикул
|
||||
* @property {number} price - Цена
|
||||
* @property {number} available_count - Доступное количество
|
||||
* @property {number} total_count - Общее количество
|
||||
* @property {string} [showcase_created_at] - Дата создания на витрине
|
||||
* @property {boolean} is_locked - Заблокирован ли
|
||||
* @property {boolean} locked_by_me - Заблокирован текущим пользователем
|
||||
* @property {string} [locked_by_user] - Кем заблокирован
|
||||
* @property {boolean} price_outdated - Неактуальная цена
|
||||
* @property {string} [image] - URL изображения
|
||||
*/
|
||||
|
||||
export class ProductManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {Object} options.currentWarehouse - Текущий склад
|
||||
* @param {Function} options.onError - Callback при ошибке
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.currentWarehouse = options.currentWarehouse || { id: null };
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
/** @type {Product[]} */
|
||||
this.items = [];
|
||||
|
||||
/** @type {ShowcaseKit[]} */
|
||||
this.showcaseKits = [];
|
||||
|
||||
/** @type {number} */
|
||||
this.currentPage = 1;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.hasMoreItems = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isLoading = false;
|
||||
|
||||
/** @type {string} */
|
||||
this.currentSearchQuery = '';
|
||||
|
||||
/** @type {number|null} */
|
||||
this.currentCategoryId = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isShowcaseView = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает товары через API
|
||||
* @param {Object} options - Опции загрузки
|
||||
* @param {boolean} options.append - Добавлять к существующим
|
||||
* @param {number} [options.categoryId] - ID категории
|
||||
* @param {string} [options.searchQuery] - Поисковый запрос
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadItems(options = {}) {
|
||||
if (this.isLoading) return false;
|
||||
|
||||
const { append = false, categoryId = null, searchQuery = '' } = options;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
if (!append) {
|
||||
this.currentPage = 1;
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
page_size: CONFIG.PAGINATION.PAGE_SIZE
|
||||
});
|
||||
|
||||
// При активном поиске игнорируем категорию
|
||||
if (categoryId && !searchQuery) {
|
||||
params.append('category_id', categoryId);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
params.append('query', searchQuery);
|
||||
}
|
||||
|
||||
const response = await safeFetch(`${CONFIG.API.ITEMS}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (append) {
|
||||
this.items = this.items.concat(data.items);
|
||||
} else {
|
||||
this.items = data.items;
|
||||
}
|
||||
|
||||
this.hasMoreItems = data.has_more;
|
||||
|
||||
if (data.has_more) {
|
||||
this.currentPage = data.next_page;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки товаров:', error);
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает витринные комплекты
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadShowcaseKits() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.SHOWCASE_KITS);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showcaseKits = data.items;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки витринных комплектов:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает единицы продажи для товара
|
||||
* @param {number} productId - ID товара
|
||||
* @returns {Promise<Array|null>}
|
||||
*/
|
||||
async loadSalesUnits(productId) {
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`${CONFIG.API.SALES_UNITS(productId)}?warehouse=${this.currentWarehouse.id}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.sales_units) {
|
||||
return data.sales_units;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.onError('Ошибка загрузки единиц продажи:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает infinite scroll
|
||||
* @param {Function} onLoad - Callback при загрузке новых элементов
|
||||
*/
|
||||
setupInfiniteScroll(onLoad) {
|
||||
const grid = document.getElementById('productGrid');
|
||||
if (!grid) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && this.hasMoreItems && !this.isLoading && !this.isShowcaseView) {
|
||||
this.loadItems({ append: true }).then(() => {
|
||||
if (onLoad) onLoad(this.items);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: CONFIG.PAGINATION.ROOT_MARGIN }
|
||||
);
|
||||
|
||||
// Наблюдаем за концом грида
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
grid.parentElement.appendChild(sentinel);
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает отфильтрованные элементы для отображения
|
||||
* @param {Object} options - Опции фильтрации
|
||||
* @param {boolean} options.wasShowcaseViewBeforeSearch - Была ли витрина активна
|
||||
* @returns {Array} Отфильтрованные элементы
|
||||
*/
|
||||
getFilteredItems(options = {}) {
|
||||
const { wasShowcaseViewBeforeSearch = false } = options;
|
||||
|
||||
// Если активен поиск (3+ символов)
|
||||
if (this.currentSearchQuery && this.currentSearchQuery.length >= CONFIG.SEARCH.MIN_LENGTH) {
|
||||
let filtered = [...this.items];
|
||||
|
||||
// Если перед началом поиска была активна витрина - добавляем витринные комплекты
|
||||
if (wasShowcaseViewBeforeSearch) {
|
||||
const searchTerm = this.currentSearchQuery.toLowerCase().trim();
|
||||
const tokens = searchTerm.split(/\s+/).filter(t => t.length > 0);
|
||||
|
||||
const filteredShowcaseKits = this.showcaseKits.filter(item => {
|
||||
const name = (item.name || '').toLowerCase();
|
||||
const sku = (item.sku || '').toLowerCase();
|
||||
return tokens.every(token => name.includes(token) || sku.includes(token));
|
||||
});
|
||||
|
||||
filtered = [...filtered, ...filteredShowcaseKits];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} else if (this.isShowcaseView) {
|
||||
// При отображении витрины возвращаем showcaseKits
|
||||
return this.showcaseKits || [];
|
||||
} else {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит карточку товара
|
||||
* @param {Product|ShowcaseKit} item - Товар или комплект
|
||||
* @param {Object} options - Опции рендера
|
||||
* @param {Map} options.cart - Корзина для проверки наличия
|
||||
* @returns {HTMLElement} DOM элемент карточки
|
||||
*/
|
||||
renderProductCard(item, options = {}) {
|
||||
const { cart = new Map() } = options;
|
||||
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card product-card';
|
||||
card.style.position = 'relative';
|
||||
|
||||
// Обработка витринных комплектов
|
||||
if (item.type === 'showcase_kit') {
|
||||
this._renderShowcaseKitBadges(card, item, cart);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
// Изображение
|
||||
const imageDiv = this._createImageElement(item.image, item.name);
|
||||
|
||||
// Информация о товаре
|
||||
const info = this._createProductInfo(item, cart);
|
||||
|
||||
body.appendChild(imageDiv);
|
||||
body.appendChild(info);
|
||||
card.appendChild(body);
|
||||
col.appendChild(card);
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает элемент изображения
|
||||
* @private
|
||||
*/
|
||||
_createImageElement(imageUrl, alt) {
|
||||
const imageDiv = document.createElement('div');
|
||||
imageDiv.className = 'product-image';
|
||||
|
||||
if (imageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = imageUrl;
|
||||
img.alt = alt;
|
||||
img.loading = 'lazy';
|
||||
imageDiv.appendChild(img);
|
||||
} else {
|
||||
imageDiv.innerHTML = '<i class="bi bi-image"></i>';
|
||||
}
|
||||
|
||||
return imageDiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает информацию о товаре
|
||||
* @private
|
||||
*/
|
||||
_createProductInfo(item, cart) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'product-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'product-name';
|
||||
name.textContent = item.name;
|
||||
|
||||
const stock = document.createElement('div');
|
||||
stock.className = 'product-stock';
|
||||
this._renderStockInfo(stock, item, cart);
|
||||
|
||||
const sku = document.createElement('div');
|
||||
sku.className = 'product-sku';
|
||||
|
||||
const skuText = document.createElement('span');
|
||||
skuText.textContent = item.sku || 'н/д';
|
||||
|
||||
const priceSpan = document.createElement('span');
|
||||
priceSpan.className = 'product-price';
|
||||
const itemPrice = item.default_sales_unit ? item.price_in_unit : item.price;
|
||||
priceSpan.textContent = formatMoney(itemPrice);
|
||||
|
||||
sku.appendChild(skuText);
|
||||
sku.appendChild(priceSpan);
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(stock);
|
||||
info.appendChild(sku);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит информацию об остатках
|
||||
* @private
|
||||
*/
|
||||
_renderStockInfo(element, item, cart) {
|
||||
// Для витринных комплектов
|
||||
if (item.type === 'showcase_kit') {
|
||||
this._renderShowcaseKitStock(element, item);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для обычных товаров с единицами продажи
|
||||
if (item.type === 'product' && item.default_sales_unit) {
|
||||
this._renderProductStockWithUnit(element, item, cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для обычных товаров без единиц продажи
|
||||
if (item.type === 'product') {
|
||||
this._renderProductStock(element, item, cart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для комплектов
|
||||
if (item.type === 'kit' && item.free_qty !== undefined) {
|
||||
this._renderKitStock(element, item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки витринного комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderShowcaseKitStock(element, item) {
|
||||
const availableCount = item.available_count || 0;
|
||||
const totalCount = item.total_count || availableCount;
|
||||
const inCart = totalCount - availableCount;
|
||||
|
||||
let badgeClass = availableCount > 0 ? 'bg-success' : 'bg-secondary';
|
||||
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
|
||||
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
|
||||
|
||||
const daysAgo = formatDaysAgo(item.showcase_created_at);
|
||||
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
|
||||
|
||||
element.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
|
||||
element.style.display = 'flex';
|
||||
element.style.justifyContent = 'space-between';
|
||||
element.style.alignItems = 'center';
|
||||
element.style.color = '#856404';
|
||||
element.style.fontWeight = 'bold';
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки товара с единицей продажи
|
||||
* @private
|
||||
*/
|
||||
_renderProductStockWithUnit(element, item, cart) {
|
||||
const unit = item.default_sales_unit;
|
||||
const conversionFactor = parseFloat(unit.conversion_factor) || 1;
|
||||
|
||||
const cartKey = `product-${item.id}-${unit.id}`;
|
||||
const inCartBaseQty = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
|
||||
const availableInUnit = parseFloat(item.available_qty_in_unit) || 0;
|
||||
const reservedInUnit = (parseFloat(item.reserved_qty) || 0) * conversionFactor;
|
||||
const freeInUnit = availableInUnit - reservedInUnit - inCartBaseQty;
|
||||
const freeRounded = roundQuantity(freeInUnit, 1);
|
||||
|
||||
const freeSpan = document.createElement('span');
|
||||
freeSpan.style.fontSize = '1.1em';
|
||||
freeSpan.style.fontWeight = 'bold';
|
||||
|
||||
const qtyText = document.createElement('span');
|
||||
qtyText.textContent = freeRounded;
|
||||
freeSpan.appendChild(qtyText);
|
||||
|
||||
const unitBadge = document.createElement('span');
|
||||
unitBadge.className = 'badge bg-secondary ms-1';
|
||||
unitBadge.style.fontSize = '0.7rem';
|
||||
unitBadge.textContent = unit.name;
|
||||
freeSpan.appendChild(unitBadge);
|
||||
|
||||
if (inCartBaseQty > 0) {
|
||||
const suffixSpan = document.createElement('span');
|
||||
suffixSpan.textContent = ` (−${roundQuantity(inCartBaseQty, 1)}🛒)`;
|
||||
suffixSpan.style.fontSize = '0.85em';
|
||||
suffixSpan.style.marginLeft = '3px';
|
||||
|
||||
element.appendChild(freeSpan);
|
||||
element.appendChild(suffixSpan);
|
||||
} else {
|
||||
element.appendChild(freeSpan);
|
||||
}
|
||||
|
||||
// Цветовая индикация
|
||||
if (freeInUnit <= 0) {
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
} else if (freeInUnit < 5) {
|
||||
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||
} else {
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки товара без единицы продажи
|
||||
* @private
|
||||
*/
|
||||
_renderProductStock(element, item, cart) {
|
||||
const available = parseFloat(item.available_qty) || 0;
|
||||
const reserved = parseFloat(item.reserved_qty) || 0;
|
||||
const cartKey = `product-${item.id}`;
|
||||
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
|
||||
|
||||
const free = available - reserved - inCart;
|
||||
const freeRounded = roundQuantity(free);
|
||||
|
||||
const freeSpan = document.createElement('span');
|
||||
freeSpan.textContent = freeRounded;
|
||||
freeSpan.style.fontSize = '1.1em';
|
||||
freeSpan.style.fontWeight = 'bold';
|
||||
|
||||
const suffixParts = [];
|
||||
if (reserved > 0) {
|
||||
suffixParts.push(`−${roundQuantity(reserved)}`);
|
||||
}
|
||||
if (inCart > 0) {
|
||||
suffixParts.push(`−${roundQuantity(inCart)}🛒`);
|
||||
}
|
||||
|
||||
if (suffixParts.length > 0) {
|
||||
const suffixSpan = document.createElement('span');
|
||||
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
|
||||
suffixSpan.style.fontSize = '0.85em';
|
||||
suffixSpan.style.marginLeft = '3px';
|
||||
|
||||
element.appendChild(freeSpan);
|
||||
element.appendChild(suffixSpan);
|
||||
} else {
|
||||
element.appendChild(freeSpan);
|
||||
}
|
||||
|
||||
if (free <= 0) {
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
} else if (free < 5) {
|
||||
element.style.color = CONFIG.CSS.STOCK_LOW;
|
||||
} else {
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит остатки комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderKitStock(element, item) {
|
||||
const availableKits = parseFloat(item.free_qty) || 0;
|
||||
if (availableKits > 0) {
|
||||
element.textContent = `В наличии: ${Math.floor(availableKits)} компл.`;
|
||||
element.style.color = CONFIG.CSS.STOCK_GOOD;
|
||||
} else {
|
||||
element.textContent = 'Под заказ';
|
||||
element.style.color = CONFIG.CSS.STOCK_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит бейджи для витринного комплекта
|
||||
* @private
|
||||
*/
|
||||
_renderShowcaseKitBadges(card, item, cart) {
|
||||
// Индикация блокировки
|
||||
if (item.is_locked) {
|
||||
const lockBadge = document.createElement('div');
|
||||
lockBadge.style.position = 'absolute';
|
||||
lockBadge.style.top = '5px';
|
||||
lockBadge.style.left = '5px';
|
||||
lockBadge.style.zIndex = '10';
|
||||
|
||||
if (item.locked_by_me) {
|
||||
lockBadge.className = 'badge bg-success';
|
||||
lockBadge.innerHTML = '<i class="bi bi-cart-check"></i> В корзине';
|
||||
lockBadge.title = 'Добавлен в вашу корзину';
|
||||
} else {
|
||||
lockBadge.className = 'badge bg-danger';
|
||||
lockBadge.innerHTML = '<i class="bi bi-lock-fill"></i> Занят';
|
||||
lockBadge.title = `В корзине ${item.locked_by_user}`;
|
||||
|
||||
card.style.opacity = '0.5';
|
||||
card.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
card.appendChild(lockBadge);
|
||||
}
|
||||
}
|
||||
}
|
||||
672
myproject/pos/static/pos/js/showcase.js
Normal file
672
myproject/pos/static/pos/js/showcase.js
Normal file
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Модуль управления витриной POS Terminal
|
||||
* Создание временных комплектов, управление букетами на витрине
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
import {
|
||||
safeJSONParse,
|
||||
roundQuantity,
|
||||
formatMoney,
|
||||
getCsrfToken,
|
||||
showToast,
|
||||
safeFetch,
|
||||
escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Showcase
|
||||
* @property {number} id - ID витрины
|
||||
* @property {string} name - Название
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TempKitData
|
||||
* @property {string} name - Название комплекта
|
||||
* @property {number} showcaseId - ID витрины
|
||||
* @property {number} quantity - Количество букетов
|
||||
* @property {string} [description] - Описание
|
||||
* @property {string} [createdAt] - Дата создания
|
||||
* @property {number} basePrice - Базовая цена
|
||||
* @property {string} adjustmentType - Тип корректировки цены
|
||||
* @property {number} adjustmentValue - Значение корректировки
|
||||
* @property {boolean} useSalePrice - Использовать свою цену
|
||||
* @property {number} [salePrice] - Цена продажи
|
||||
*/
|
||||
|
||||
export class ShowcaseManager {
|
||||
/**
|
||||
* @param {Object} options - Опции
|
||||
* @param {import('./cart.js').Cart} options.cart - Экземпляр корзины
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.cart = options.cart;
|
||||
|
||||
// Состояние
|
||||
this.isEditMode = false;
|
||||
this.editingKitId = null;
|
||||
this.showcases = [];
|
||||
this.tempCart = new Map(); // Временная корзина для модального окна
|
||||
|
||||
// Модальное окно
|
||||
this.modalInstance = null;
|
||||
|
||||
// Callback'и
|
||||
this.onKitCreated = options.onKitCreated || null;
|
||||
this.onKitUpdated = options.onKitUpdated || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует модуль
|
||||
*/
|
||||
init() {
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики событий
|
||||
*/
|
||||
initEventListeners() {
|
||||
// Кнопка "На витрину"
|
||||
document.getElementById('addToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||
document.getElementById('mobileAddToShowcaseBtn')?.addEventListener('click', () => this.openCreateModal());
|
||||
|
||||
// Кнопки модального окна
|
||||
document.getElementById('confirmCreateTempKit')?.addEventListener('click', () => this.confirmCreateKit());
|
||||
document.getElementById('disassembleKitBtn')?.addEventListener('click', () => this.disassembleKit());
|
||||
document.getElementById('writeOffKitBtn')?.addEventListener('click', () => this.writeOffKit());
|
||||
|
||||
// Загрузка фото
|
||||
document.getElementById('tempKitPhoto')?.addEventListener('change', (e) => this.handlePhotoUpload(e));
|
||||
document.getElementById('removePhoto')?.addEventListener('click', () => this.removePhoto());
|
||||
|
||||
// Корректировка цены
|
||||
document.getElementById('priceAdjustmentType')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||
document.getElementById('priceAdjustmentValue')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||
document.getElementById('useSalePrice')?.addEventListener('change', () => this.updatePriceCalculation());
|
||||
document.getElementById('salePrice')?.addEventListener('input', () => this.updatePriceCalculation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно создания комплекта
|
||||
*/
|
||||
async openCreateModal() {
|
||||
if (this.cart.isEmpty) {
|
||||
showToast('error', 'Корзина пуста. Добавьте товары для создания комплекта.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditMode = false;
|
||||
this.editingKitId = null;
|
||||
this.tempCart = new Map(this.cart.items);
|
||||
|
||||
// Загружаем список витрин
|
||||
await this.loadShowcases();
|
||||
|
||||
// Сбрасываем поля формы
|
||||
this.resetForm();
|
||||
|
||||
// Заполняем список товаров
|
||||
this.renderTempKitItems();
|
||||
|
||||
// Рассчитываем цены
|
||||
this.updatePriceCalculation();
|
||||
|
||||
// Показываем модалку
|
||||
const modalEl = document.getElementById('createTempKitModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Обновляем UI для режима создания
|
||||
this.updateModalUIForMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модальное окно редактирования комплекта
|
||||
* @param {number} kitId - ID комплекта
|
||||
*/
|
||||
async openEditModal(kitId) {
|
||||
this.isEditMode = true;
|
||||
this.editingKitId = kitId;
|
||||
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.PRODUCT_KIT(kitId));
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast('error', 'Не удалось загрузить данные комплекта');
|
||||
return;
|
||||
}
|
||||
|
||||
const kit = data.kit;
|
||||
|
||||
// Загружаем список витрин
|
||||
await this.loadShowcases();
|
||||
|
||||
// Заполняем поля формы
|
||||
document.getElementById('tempKitName').value = kit.name;
|
||||
document.getElementById('showcaseSelect').value = kit.showcase_id || '';
|
||||
document.getElementById('tempKitDescription').value = kit.description || '';
|
||||
document.getElementById('showcaseCreatedAt').value = kit.showcase_created_at || '';
|
||||
|
||||
// Скрываем блок количества при редактировании
|
||||
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||
|
||||
// Заполняем временную корзину
|
||||
this.tempCart.clear();
|
||||
if (kit.items) {
|
||||
kit.items.forEach(item => {
|
||||
const key = `product-${item.product_id}-${item.sales_unit_id || 'base'}`;
|
||||
this.tempCart.set(key, {
|
||||
id: item.product_id,
|
||||
name: item.product_name,
|
||||
price: item.price,
|
||||
qty: item.quantity,
|
||||
type: 'product',
|
||||
sales_unit_id: item.sales_unit_id,
|
||||
unit_name: item.unit_name
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Заполняем цены
|
||||
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type || 'none';
|
||||
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value || 0;
|
||||
document.getElementById('useSalePrice').checked = kit.use_sale_price || false;
|
||||
document.getElementById('salePrice').value = kit.sale_price || '';
|
||||
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
|
||||
// Показываем модалку
|
||||
const modalEl = document.getElementById('createTempKitModal');
|
||||
if (!modalEl) return;
|
||||
|
||||
this.modalInstance = new bootstrap.Modal(modalEl);
|
||||
this.modalInstance.show();
|
||||
|
||||
// Обновляем UI для режима редактирования
|
||||
this.updateModalUIForMode();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки комплекта:', error);
|
||||
showToast('error', 'Ошибка при загрузке данных комплекта');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет UI модального окна в зависимости от режима
|
||||
*/
|
||||
updateModalUIForMode() {
|
||||
const title = document.getElementById('createTempKitModalLabel');
|
||||
const confirmBtn = document.getElementById('confirmCreateTempKit');
|
||||
const disassembleBtn = document.getElementById('disassembleKitBtn');
|
||||
const writeOffBtn = document.getElementById('writeOffKitBtn');
|
||||
const addProductBlock = document.getElementById('addProductBlock');
|
||||
const qtyBlock = document.getElementById('showcaseKitQuantityBlock');
|
||||
|
||||
if (this.isEditMode) {
|
||||
if (title) title.innerHTML = '<i class="bi bi-pencil-square"></i> Редактировать комплект на витрине';
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Сохранить изменения';
|
||||
}
|
||||
if (disassembleBtn) disassembleBtn.style.display = 'block';
|
||||
if (writeOffBtn) writeOffBtn.style.display = 'block';
|
||||
if (addProductBlock) addProductBlock.style.display = 'block';
|
||||
if (qtyBlock) qtyBlock.style.display = 'none';
|
||||
} else {
|
||||
if (title) title.innerHTML = '<i class="bi bi-flower1"></i> Создать временный комплект на витрину';
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
|
||||
}
|
||||
if (disassembleBtn) disassembleBtn.style.display = 'none';
|
||||
if (writeOffBtn) writeOffBtn.style.display = 'none';
|
||||
if (addProductBlock) addProductBlock.style.display = 'none';
|
||||
if (qtyBlock) qtyBlock.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает список витрин
|
||||
*/
|
||||
async loadShowcases() {
|
||||
try {
|
||||
const response = await safeFetch(CONFIG.API.GET_SHOWCASES);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showcases = data.showcases || [];
|
||||
this.renderShowcaseSelect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки витрин:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит select с витринами
|
||||
*/
|
||||
renderShowcaseSelect() {
|
||||
const select = document.getElementById('showcaseSelect');
|
||||
if (!select) return;
|
||||
|
||||
let html = '<option value="">Выберите витрину...</option>';
|
||||
this.showcases.forEach(showcase => {
|
||||
html += `<option value="${showcase.id}">${escapeHtml(showcase.name)}</option>`;
|
||||
});
|
||||
|
||||
select.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит список товаров во временной корзине
|
||||
*/
|
||||
renderTempKitItems() {
|
||||
const container = document.getElementById('tempKitItemsList');
|
||||
if (!container) return;
|
||||
|
||||
if (this.tempCart.size === 0) {
|
||||
container.innerHTML = '<p class="text-muted text-center mb-0">Нет товаров</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
let totalBasePrice = 0;
|
||||
|
||||
this.tempCart.forEach((item, key) => {
|
||||
const itemTotal = item.price * item.qty;
|
||||
totalBasePrice += itemTotal;
|
||||
|
||||
html += `
|
||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom">
|
||||
<div class="flex-grow-1">
|
||||
<div class="small">${escapeHtml(item.name)}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">
|
||||
${formatMoney(item.price)} × ${roundQuantity(item.qty)}
|
||||
${item.unit_name ? ' ' + item.unit_name : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fw-semibold small">${formatMoney(itemTotal)}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Обновляем базовую цену
|
||||
document.getElementById('tempKitBasePrice').textContent = formatMoney(totalBasePrice) + ' руб.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет расчёт цен
|
||||
*/
|
||||
updatePriceCalculation() {
|
||||
const basePriceEl = document.getElementById('tempKitBasePrice');
|
||||
const basePrice = parseFloat(basePriceEl?.textContent) || 0;
|
||||
|
||||
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||
|
||||
// Показываем/скрываем блок значения корректировки
|
||||
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
|
||||
if (adjustmentBlock) {
|
||||
adjustmentBlock.style.display = adjustmentType === 'none' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Показываем/скрываем блок цены продажи
|
||||
const salePriceBlock = document.getElementById('salePriceBlock');
|
||||
if (salePriceBlock) {
|
||||
salePriceBlock.style.display = useSalePrice ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Рассчитываем цену с корректировкой
|
||||
let calculatedPrice = basePrice;
|
||||
|
||||
if (adjustmentType !== 'none' && adjustmentValue > 0) {
|
||||
switch (adjustmentType) {
|
||||
case 'increase_percent':
|
||||
calculatedPrice = basePrice * (1 + adjustmentValue / 100);
|
||||
break;
|
||||
case 'increase_amount':
|
||||
calculatedPrice = basePrice + adjustmentValue;
|
||||
break;
|
||||
case 'decrease_percent':
|
||||
calculatedPrice = basePrice * (1 - adjustmentValue / 100);
|
||||
break;
|
||||
case 'decrease_amount':
|
||||
calculatedPrice = basePrice - adjustmentValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.';
|
||||
|
||||
// Итоговая цена
|
||||
const finalPrice = useSalePrice && salePrice > 0 ? salePrice : calculatedPrice;
|
||||
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает загрузку фото
|
||||
*/
|
||||
handlePhotoUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const preview = document.getElementById('photoPreview');
|
||||
const img = document.getElementById('photoPreviewImg');
|
||||
if (preview && img) {
|
||||
img.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет загруженное фото
|
||||
*/
|
||||
removePhoto() {
|
||||
const input = document.getElementById('tempKitPhoto');
|
||||
const preview = document.getElementById('photoPreview');
|
||||
if (input) input.value = '';
|
||||
if (preview) preview.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает форму
|
||||
*/
|
||||
resetForm() {
|
||||
document.getElementById('tempKitName').value = '';
|
||||
document.getElementById('showcaseSelect').value = '';
|
||||
document.getElementById('showcaseKitQuantity').value = '1';
|
||||
document.getElementById('tempKitDescription').value = '';
|
||||
document.getElementById('showcaseCreatedAt').value = '';
|
||||
document.getElementById('priceAdjustmentType').value = 'none';
|
||||
document.getElementById('priceAdjustmentValue').value = '0';
|
||||
document.getElementById('useSalePrice').checked = false;
|
||||
document.getElementById('salePrice').value = '';
|
||||
this.removePhoto();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает данные формы
|
||||
*/
|
||||
getFormData() {
|
||||
const name = document.getElementById('tempKitName')?.value.trim();
|
||||
const showcaseId = parseInt(document.getElementById('showcaseSelect')?.value);
|
||||
const quantity = parseInt(document.getElementById('showcaseKitQuantity')?.value) || 1;
|
||||
const description = document.getElementById('tempKitDescription')?.value.trim();
|
||||
const createdAt = document.getElementById('showcaseCreatedAt')?.value;
|
||||
|
||||
const adjustmentType = document.getElementById('priceAdjustmentType')?.value || 'none';
|
||||
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue')?.value) || 0;
|
||||
const useSalePrice = document.getElementById('useSalePrice')?.checked || false;
|
||||
const salePrice = parseFloat(document.getElementById('salePrice')?.value) || 0;
|
||||
|
||||
// Получаем фото
|
||||
const photoInput = document.getElementById('tempKitPhoto');
|
||||
const photoFile = photoInput?.files[0];
|
||||
|
||||
return {
|
||||
name,
|
||||
showcaseId,
|
||||
quantity,
|
||||
description,
|
||||
createdAt,
|
||||
adjustmentType,
|
||||
adjustmentValue,
|
||||
useSalePrice,
|
||||
salePrice,
|
||||
photoFile
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует данные формы
|
||||
*/
|
||||
validateForm(data) {
|
||||
if (!data.name) {
|
||||
showToast('error', 'Укажите название комплекта');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.showcaseId) {
|
||||
showToast('error', 'Выберите витрину');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.tempCart.size === 0) {
|
||||
showToast('error', 'Добавьте товары в комплект');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает создание/обновление комплекта
|
||||
*/
|
||||
async confirmCreateKit() {
|
||||
const data = this.getFormData();
|
||||
|
||||
if (!this.validateForm(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем данные для отправки
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('showcase_id', data.showcaseId);
|
||||
formData.append('description', data.description || '');
|
||||
formData.append('showcase_created_at', data.createdAt || '');
|
||||
formData.append('price_adjustment_type', data.adjustmentType);
|
||||
formData.append('price_adjustment_value', data.adjustmentValue);
|
||||
formData.append('use_sale_price', data.useSalePrice);
|
||||
formData.append('sale_price', data.salePrice || 0);
|
||||
|
||||
if (!this.isEditMode) {
|
||||
formData.append('quantity', data.quantity);
|
||||
}
|
||||
|
||||
// Добавляем товары
|
||||
const items = [];
|
||||
this.tempCart.forEach((item, key) => {
|
||||
items.push({
|
||||
product_id: item.id,
|
||||
quantity: item.qty,
|
||||
price: item.price,
|
||||
sales_unit_id: item.sales_unit_id || null
|
||||
});
|
||||
});
|
||||
formData.append('items', JSON.stringify(items));
|
||||
|
||||
// Добавляем фото
|
||||
if (data.photoFile) {
|
||||
formData.append('photo', data.photoFile);
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (this.isEditMode) {
|
||||
// Обновление существующего комплекта
|
||||
response = await fetch(CONFIG.API.PRODUCT_KIT_UPDATE(this.editingKitId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
} else {
|
||||
// Создание нового комплекта
|
||||
response = await fetch(CONFIG.API.CREATE_TEMP_KIT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', this.isEditMode ? 'Комплект обновлён' : 'Комплект создан и размещён на витрине');
|
||||
|
||||
// Очищаем корзину
|
||||
this.cart.clear();
|
||||
|
||||
// Закрываем модалку
|
||||
this.close();
|
||||
|
||||
// Вызываем callback
|
||||
if (this.isEditMode && this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
} else if (!this.isEditMode && this.onKitCreated) {
|
||||
this.onKitCreated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при сохранении комплекта');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при сохранении комплекта');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает комплект
|
||||
*/
|
||||
async disassembleKit() {
|
||||
if (!this.isEditMode || !this.editingKitId) return;
|
||||
|
||||
if (!confirm('Разобрать букет и вернуть товары на склад?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.PRODUCT_KIT_DISASSEMBLE(this.editingKitId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', 'Букет разобран, товары возвращены на склад');
|
||||
this.close();
|
||||
|
||||
// Обновляем отображение
|
||||
if (this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при разборке букета');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при разборке комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при разборке букета');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Списывает комплект
|
||||
*/
|
||||
async writeOffKit() {
|
||||
if (!this.isEditMode || !this.editingKitId) return;
|
||||
|
||||
if (!confirm('Списать букет? Товары будут удалены из учёта.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
CONFIG.API.PRODUCT_KIT_WRITE_OFF(this.editingKitId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('success', 'Букет списан');
|
||||
this.close();
|
||||
|
||||
// Обновляем отображение
|
||||
if (this.onKitUpdated) {
|
||||
this.onKitUpdated(result);
|
||||
}
|
||||
} else {
|
||||
showToast('error', result.error || 'Ошибка при списании букета');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при списании комплекта:', error);
|
||||
showToast('error', 'Ошибка сети при списании букета');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает модальное окно
|
||||
*/
|
||||
close() {
|
||||
if (this.modalInstance) {
|
||||
this.modalInstance.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет товар во временную корзину (для режима редактирования)
|
||||
* @param {Object} product - Данные товара
|
||||
* @param {number} quantity - Количество
|
||||
*/
|
||||
addProductToTempCart(product, quantity = 1) {
|
||||
const key = `product-${product.id}-${product.sales_unit_id || 'base'}`;
|
||||
|
||||
if (this.tempCart.has(key)) {
|
||||
const existing = this.tempCart.get(key);
|
||||
existing.qty = roundQuantity(existing.qty + quantity);
|
||||
} else {
|
||||
this.tempCart.set(key, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
qty: quantity,
|
||||
type: 'product',
|
||||
sales_unit_id: product.sales_unit_id,
|
||||
unit_name: product.unit_name
|
||||
});
|
||||
}
|
||||
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет товар из временной корзины
|
||||
* @param {string} key - Ключ товара
|
||||
*/
|
||||
removeProductFromTempCart(key) {
|
||||
this.tempCart.delete(key);
|
||||
this.renderTempKitItems();
|
||||
this.updatePriceCalculation();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
286
myproject/pos/static/pos/js/utils.js
Normal file
286
myproject/pos/static/pos/js/utils.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Утилиты POS Terminal
|
||||
* Общие функции для работы с числами, датами, DOM и т.д.
|
||||
*/
|
||||
|
||||
import CONFIG from './config.js';
|
||||
|
||||
/**
|
||||
* Безопасный парсинг JSON с обработкой ошибок
|
||||
* @param {string} jsonString - JSON строка для парсинга
|
||||
* @param {*} defaultValue - Значение по умолчанию при ошибке
|
||||
* @returns {*} Распарсенный объект или defaultValue
|
||||
*/
|
||||
export function safeJSONParse(jsonString, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
console.error('Ошибка парсинга JSON:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Округляет число до N знаков после запятой
|
||||
* Решает проблему погрешности float arithmetic в JavaScript
|
||||
* @param {number} value - Число для округления
|
||||
* @param {number} decimals - Количество знаков после запятой
|
||||
* @returns {number} Округлённое число
|
||||
*/
|
||||
export function roundQuantity(value, decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY) {
|
||||
if (value === null || value === undefined || isNaN(value)) return 0;
|
||||
if (!Number.isInteger(decimals) || decimals < 0) decimals = CONFIG.NUMBERS.DECIMALS_QUANTITY;
|
||||
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует число как денежную сумму
|
||||
* @param {number} value - Сумма
|
||||
* @returns {string} Отформатированная сумма
|
||||
*/
|
||||
export function formatMoney(value) {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return '0.00';
|
||||
return num.toFixed(CONFIG.NUMBERS.DECIMALS_MONEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату как относительное время в русском языке
|
||||
* @param {string|null} isoDate - ISO дата или null
|
||||
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
|
||||
*/
|
||||
export function formatDaysAgo(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
|
||||
const created = new Date(isoDate);
|
||||
if (isNaN(created.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - created;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Русские формы множественного числа
|
||||
const lastTwo = diffDays % 100;
|
||||
const lastOne = diffDays % 10;
|
||||
|
||||
let suffix;
|
||||
if (lastTwo >= 11 && lastTwo <= 19) {
|
||||
suffix = 'дней';
|
||||
} else if (lastOne === 1) {
|
||||
suffix = 'день';
|
||||
} else if (lastOne >= 2 && lastOne <= 4) {
|
||||
suffix = 'дня';
|
||||
} else {
|
||||
suffix = 'дней';
|
||||
}
|
||||
|
||||
return `${diffDays} ${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Склонение слов в зависимости от числа
|
||||
* @param {number} number - число
|
||||
* @param {string} one - форма для 1 (товар)
|
||||
* @param {string} two - форма для 2-4 (товара)
|
||||
* @param {string} five - форма для 5+ (товаров)
|
||||
* @returns {string} Правильная форма слова
|
||||
*/
|
||||
export function getNoun(number, one, two, five) {
|
||||
const n = Math.abs(number);
|
||||
const n10 = n % 10;
|
||||
const n100 = n % 100;
|
||||
|
||||
if (n100 >= 11 && n100 <= 19) {
|
||||
return five;
|
||||
}
|
||||
if (n10 === 1) {
|
||||
return one;
|
||||
}
|
||||
if (n10 >= 2 && n10 <= 4) {
|
||||
return two;
|
||||
}
|
||||
return five;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce функция
|
||||
* @param {Function} func - Функция для debounce
|
||||
* @param {number} wait - Задержка в миллисекундах
|
||||
* @returns {Function} Функция с debounce
|
||||
*/
|
||||
export function debounce(func, wait = CONFIG.TIMEOUTS.DEBOUNCE_SEARCH) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF токен из DOM или cookie
|
||||
* @returns {string|null} CSRF токен
|
||||
*/
|
||||
export function getCsrfToken() {
|
||||
// Пытаемся найти токен в DOM (из {% csrf_token %})
|
||||
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfInput) {
|
||||
return csrfInput.value;
|
||||
}
|
||||
|
||||
// Fallback: пытаемся прочитать из cookie
|
||||
return getCookie('csrftoken');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает значение cookie по имени
|
||||
* @param {string} name - Имя cookie
|
||||
* @returns {string|null} Значение cookie
|
||||
*/
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли устройство мобильным
|
||||
* @returns {boolean} true если мобильное устройство
|
||||
*/
|
||||
export function isMobileDevice() {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
|
||||
// Проверка по юзер-агенту
|
||||
const isMobileUA = CONFIG.MOBILE.USER_AGENTS.some(keyword =>
|
||||
userAgent.indexOf(keyword) > -1
|
||||
);
|
||||
|
||||
// Проверка по размеру экрана
|
||||
const isSmallScreen = window.innerWidth < CONFIG.MOBILE.SCREEN_WIDTH_THRESHOLD;
|
||||
|
||||
return isMobileUA || isSmallScreen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает toast уведомление
|
||||
* @param {'success'|'error'} type - Тип уведомления
|
||||
* @param {string} message - Текст сообщения
|
||||
*/
|
||||
export function showToast(type, message) {
|
||||
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
|
||||
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
|
||||
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
|
||||
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const messageElement = document.getElementById(messageId);
|
||||
|
||||
if (!toastElement || !messageElement) {
|
||||
console.warn('Toast элементы не найдены в DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Устанавливаем сообщение
|
||||
messageElement.textContent = message;
|
||||
|
||||
// Добавляем цвет фона
|
||||
toastElement.classList.add(bgClass, 'text-white');
|
||||
|
||||
// Создаём и показываем toast
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
delay: CONFIG.TIMEOUTS.TOAST_DELAY,
|
||||
autohide: true
|
||||
});
|
||||
toast.show();
|
||||
|
||||
// Убираем класс цвета после скрытия
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.classList.remove(bgClass, 'text-white');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное экранирование HTML
|
||||
* @param {string} text - Текст для экранирования
|
||||
* @returns {string} Экранированный текст
|
||||
*/
|
||||
export function escapeHtml(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает иконку для режима объединения скидок
|
||||
* @param {string} mode - Режим объединения
|
||||
* @returns {string} HTML иконки
|
||||
*/
|
||||
export function getCombineModeIcon(mode) {
|
||||
const icons = {
|
||||
'stack': '<i class="bi bi-layers" title="Складывать (суммировать)"></i>',
|
||||
'max_only': '<i class="bi bi-trophy" title="Только максимум"></i>',
|
||||
'exclusive': '<i class="bi bi-x-circle" title="Исключающая (отменяет остальные)"></i>'
|
||||
};
|
||||
return icons[mode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает описание режима объединения скидок
|
||||
* @param {string} mode - Режим объединения
|
||||
* @returns {string} Описание
|
||||
*/
|
||||
export function getCombineModeTitle(mode) {
|
||||
const titles = {
|
||||
'stack': 'Складывается с другими скидками',
|
||||
'max_only': 'Применяется только максимальная из этого типа',
|
||||
'exclusive': 'Отменяет все другие скидки'
|
||||
};
|
||||
return titles[mode] || mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает AbortController с таймаутом
|
||||
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||
* @returns {AbortController} AbortController с таймаутом
|
||||
*/
|
||||
export function createAbortController(timeoutMs = 10000) {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасный fetch с обработкой ошибок и таймаутом
|
||||
* @param {string} url - URL для запроса
|
||||
* @param {Object} options - Опции fetch
|
||||
* @param {number} timeoutMs - Таймаут в миллисекундах
|
||||
* @returns {Promise<Response>} Ответ от сервера
|
||||
*/
|
||||
export async function safeFetch(url, options = {}, timeoutMs = 10000) {
|
||||
const controller = createAbortController(timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -769,6 +769,6 @@
|
||||
</script>
|
||||
|
||||
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
|
||||
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
||||
<script type="module" src="{% static 'pos/js/terminal.js' %}"></script>
|
||||
<script src="{% static 'pos/js/cart-item-editor.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user