Рефакторинг POS терминала с использованием ES6 модулей

This commit is contained in:
2026-01-27 18:27:24 +03:00
parent dbadd14913
commit 9dccc1f4b5
11 changed files with 4321 additions and 3757 deletions

View File

@@ -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');
if (!priceInput || !qtyInput) return;
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
document.getElementById('editModalTotal').textContent = fmtMoney(price * qty) + ' руб.';
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');
if (!priceInput) return;
const item = window.cart?.get(editingCartKey);
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

View 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;
}
}
}

View 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
};
}
}

View 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;

View 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;
}
}

View 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
};
}

View 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);
}
}
}

View 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

View 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;
}
}

View File

@@ -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 %}