diff --git a/myproject/orders/services/transaction_service.py b/myproject/orders/services/transaction_service.py index 1b2fa32..b59beae 100644 --- a/myproject/orders/services/transaction_service.py +++ b/myproject/orders/services/transaction_service.py @@ -235,3 +235,74 @@ class TransactionService: Decimal: Сумма, которую можно вернуть """ return max(order.amount_paid, Decimal('0')) + + @staticmethod + def get_available_payment_methods(exclude_codes=None, only_active=True): + """ + Получить список доступных способов оплаты. + + Args: + exclude_codes: list[str] - коды для исключения (например ['legal_entity']) + only_active: bool - только активные методы + + Returns: + QuerySet[PaymentMethod] + """ + from orders.models import PaymentMethod + + qs = PaymentMethod.objects.all() + + if only_active: + qs = qs.filter(is_active=True) + + if exclude_codes: + qs = qs.exclude(code__in=exclude_codes) + + return qs.order_by('order', 'name') + + @staticmethod + @transaction.atomic + def create_multiple_payments(order, payments_list, user): + """ + Создать несколько платежей за одну транзакцию (смешанная оплата). + + Args: + order: Order + payments_list: list[dict] - [{'payment_method': code_or_object, 'amount': Decimal, 'notes': str}, ...] + user: CustomUser + + Returns: + list[Transaction] + + Raises: + ValidationError: если сумма превышает amount_due или недостаточно средств + """ + from orders.models import Transaction + + transactions = [] + total_amount = Decimal('0') + + # Валидация общей суммы + for payment_data in payments_list: + amount = _quantize(payment_data['amount']) + if amount <= 0: + raise ValueError(f'Сумма платежа должна быть положительной: {amount}') + total_amount += amount + + if total_amount > order.amount_due: + raise ValidationError( + f'Общая сумма платежей ({total_amount}) превышает сумму к оплате ({order.amount_due})' + ) + + # Создаём транзакции + for payment_data in payments_list: + txn = TransactionService.create_payment( + order=order, + amount=payment_data['amount'], + payment_method=payment_data['payment_method'], + user=user, + notes=payment_data.get('notes') + ) + transactions.append(txn) + + return transactions diff --git a/myproject/orders/static/orders/js/payment_widget.js b/myproject/orders/static/orders/js/payment_widget.js new file mode 100644 index 0000000..ed72111 --- /dev/null +++ b/myproject/orders/static/orders/js/payment_widget.js @@ -0,0 +1,370 @@ +/** + * PaymentWidget - переиспользуемый виджет для оплаты заказа. + * Поддерживает одиночную и смешанную оплату. + * + * @example + * const widget = new PaymentWidget({ + * containerId: 'paymentContainer', + * mode: 'single', // или 'mixed' + * order: { id: 1, total: 5000, amount_due: 5000 }, + * customer: { id: 1, wallet_balance: 1000 }, + * paymentMethods: [ + * { id: 1, code: 'account_balance', name: 'С баланса счёта' }, + * { id: 2, code: 'cash', name: 'Наличными' }, + * ... + * ], + * onSubmit: (payments) => { console.log(payments); } + * }); + */ +export class PaymentWidget { + constructor(options = {}) { + this.containerId = options.containerId; + this.mode = options.mode || 'single'; // 'single' | 'mixed' + this.order = options.order; + this.customer = options.customer; + this.paymentMethods = options.paymentMethods || []; + this.onSubmit = options.onSubmit || (() => {}); + + // Внутреннее состояние + this.payments = []; // [{ method_id, method_code, method_name, amount, notes }] + this.selectedMethod = null; // Для single mode + + this.init(); + } + + init() { + this.render(); + this.attachEvents(); + } + + render() { + const container = document.getElementById(this.containerId); + if (!container) { + console.error(`Container #${this.containerId} not found`); + return; + } + + container.innerHTML = ` + +
+ +
+ ${this.renderPaymentMethods()} +
+
+ + ${this.mode === 'mixed' ? this.renderPaymentsList() : ''} + + +
+ +
+ + руб. +
+ +
+ + +
+ + +
+ + +
+ ${this.mode === 'mixed' ? ` + + ` : ''} + +
+ `; + } + + renderPaymentMethods() { + return this.paymentMethods.map(method => { + const showWallet = method.code === 'account_balance' && this.customer; + return ` + + `; + }).join(''); + } + + renderPaymentsList() { + return ` +
+ +
+ Платежи не добавлены +
+
+ `; + } + + attachEvents() { + // Выбор способа оплаты + document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(btn => { + btn.addEventListener('click', () => this.selectPaymentMethod(btn)); + }); + + // Добавить платеж (mixed mode) + if (this.mode === 'mixed') { + const addBtn = document.getElementById(`${this.containerId}-add-btn`); + if (addBtn) { + addBtn.addEventListener('click', () => this.addPayment()); + } + } + + // Отправка + const submitBtn = document.getElementById(`${this.containerId}-submit-btn`); + if (submitBtn) { + submitBtn.addEventListener('click', () => this.submit()); + } + } + + selectPaymentMethod(btnElement) { + // Снимаем выделение + document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => { + b.classList.remove('active'); + }); + + // Выделяем выбранный + btnElement.classList.add('active'); + + this.selectedMethod = { + id: btnElement.dataset.methodId, + code: btnElement.dataset.methodCode, + name: btnElement.dataset.methodName + }; + + // Валидация для кошелька + const amountInput = document.getElementById(`${this.containerId}-amount`); + const hint = document.getElementById(`${this.containerId}-hint`); + + if (this.selectedMethod.code === 'account_balance' && this.customer) { + const maxAmount = Math.min(this.order.amount_due, this.customer.wallet_balance); + amountInput.max = maxAmount; + amountInput.value = maxAmount.toFixed(2); + hint.textContent = `Макс: ${maxAmount.toFixed(2)} руб.`; + } else { + amountInput.removeAttribute('max'); + amountInput.value = this.order.amount_due.toFixed(2); + hint.textContent = ''; + } + } + + addPayment() { + if (!this.selectedMethod) { + alert('Выберите способ оплаты'); + return; + } + + const amountInput = document.getElementById(`${this.containerId}-amount`); + const notesInput = document.getElementById(`${this.containerId}-notes`); + + const amount = parseFloat(amountInput.value); + + if (amount <= 0 || isNaN(amount)) { + alert('Введите сумму больше 0'); + return; + } + + // Проверка не превышает ли общая сумма amount_due + const currentTotal = this.getTotalPayments(); + if (currentTotal + amount > this.order.amount_due) { + alert(`Общая сумма платежей (${(currentTotal + amount).toFixed(2)}) превышает сумму к оплате (${this.order.amount_due.toFixed(2)})`); + return; + } + + // Добавляем в список + this.payments.push({ + method_id: this.selectedMethod.id, + method_code: this.selectedMethod.code, + method_name: this.selectedMethod.name, + amount: amount, + notes: notesInput.value.trim() + }); + + // Обновляем UI + this.updatePaymentsList(); + + // Сбрасываем форму + const remaining = this.order.amount_due - this.getTotalPayments(); + amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00'; + notesInput.value = ''; + + // Снимаем выделение способа + document.querySelectorAll(`#${this.containerId}-methods .payment-method-btn`).forEach(b => { + b.classList.remove('active'); + }); + this.selectedMethod = null; + + const hint = document.getElementById(`${this.containerId}-hint`); + hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : 'Оплачено полностью'; + } + + removePayment(index) { + this.payments.splice(index, 1); + this.updatePaymentsList(); + + // Обновляем подсказку + const amountInput = document.getElementById(`${this.containerId}-amount`); + const remaining = this.order.amount_due - this.getTotalPayments(); + amountInput.value = remaining > 0 ? remaining.toFixed(2) : '0.00'; + + const hint = document.getElementById(`${this.containerId}-hint`); + hint.textContent = remaining > 0 ? `Осталось оплатить: ${remaining.toFixed(2)} руб.` : ''; + } + + updatePaymentsList() { + const listContainer = document.getElementById(`${this.containerId}-payments-list`); + if (!listContainer) return; + + if (this.payments.length === 0) { + listContainer.innerHTML = 'Платежи не добавлены'; + return; + } + + listContainer.innerHTML = this.payments.map((p, index) => ` +
+
+ ${p.method_name} + ${p.amount.toFixed(2)} руб. + ${p.notes ? `
${p.notes}` : ''} +
+ +
+ `).join(''); + + // Прикрепляем обработчики удаления + listContainer.querySelectorAll('.remove-payment-btn').forEach(btn => { + btn.addEventListener('click', () => { + const index = parseInt(btn.dataset.index); + this.removePayment(index); + }); + }); + } + + getTotalPayments() { + return this.payments.reduce((sum, p) => sum + p.amount, 0); + } + + validate() { + if (this.mode === 'single') { + // Одиночная оплата + if (!this.selectedMethod) { + throw new Error('Выберите способ оплаты'); + } + + const amountInput = document.getElementById(`${this.containerId}-amount`); + const amount = parseFloat(amountInput.value); + + if (amount <= 0 || isNaN(amount)) { + throw new Error('Введите сумму больше 0'); + } + + if (amount > this.order.amount_due) { + throw new Error(`Сумма превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`); + } + + // Проверка кошелька + if (this.selectedMethod.code === 'account_balance' && this.customer) { + if (amount > this.customer.wallet_balance) { + throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`); + } + } + + } else { + // Смешанная оплата + if (this.payments.length === 0) { + throw new Error('Добавьте хотя бы один платеж'); + } + + const total = this.getTotalPayments(); + + if (total > this.order.amount_due) { + throw new Error(`Сумма платежей превышает остаток к оплате (${this.order.amount_due.toFixed(2)} руб.)`); + } + + // Проверка кошелька + if (this.customer) { + const walletPayments = this.payments.filter(p => p.method_code === 'account_balance'); + const walletTotal = walletPayments.reduce((sum, p) => sum + p.amount, 0); + + if (walletTotal > this.customer.wallet_balance) { + throw new Error(`Недостаточно средств в кошельке (${this.customer.wallet_balance.toFixed(2)} руб.)`); + } + } + } + + return true; + } + + submit() { + try { + this.validate(); + + let paymentsData; + + if (this.mode === 'single') { + // Одиночная оплата + const amountInput = document.getElementById(`${this.containerId}-amount`); + const notesInput = document.getElementById(`${this.containerId}-notes`); + + paymentsData = [{ + payment_method: this.selectedMethod.code, + amount: parseFloat(amountInput.value), + notes: notesInput.value.trim() + }]; + } else { + // Смешанная оплата + paymentsData = this.payments.map(p => ({ + payment_method: p.method_code, + amount: p.amount, + notes: p.notes + })); + } + + this.onSubmit(paymentsData); + + } catch (error) { + alert(error.message); + } + } +} diff --git a/myproject/orders/templatetags/payment_tags.py b/myproject/orders/templatetags/payment_tags.py new file mode 100644 index 0000000..0acd565 --- /dev/null +++ b/myproject/orders/templatetags/payment_tags.py @@ -0,0 +1,30 @@ +""" +Template tags для работы с платежами. +""" +from django import template +from orders.services.transaction_service import TransactionService + +register = template.Library() + + +@register.simple_tag +def get_payment_methods_for_pos(): + """ + Получить способы оплаты для POS (исключая legal_entity). + Используется в шаблонах POS. + """ + return TransactionService.get_available_payment_methods( + exclude_codes=['legal_entity'], + only_active=True + ) + + +@register.filter +def format_currency(value): + """ + Форматирование валюты: 1234.50 -> '1 234.50 руб.' + """ + try: + return f"{float(value):,.2f}".replace(',', ' ') + " руб." + except (ValueError, TypeError): + return "0.00 руб." diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 5013860..b7248d4 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -1513,123 +1513,183 @@ function renderCheckoutModal() { // Обновляем информацию о клиенте updateCustomerDisplay(); - - // Обновляем базовую цену и пересчитываем - updateCheckoutPricing(total); } -// Пересчёт цен в модалке продажи -function updateCheckoutPricing(basePrice = null) { - // Если basePrice не передан, пересчитываем из корзины - if (basePrice === null) { - basePrice = 0; - cart.forEach((item) => { - basePrice += item.qty * item.price; - }); +// ===== CHECKOUT: ПОДТВЕРЖДЕНИЕ ПРОДАЖИ ===== + +let paymentWidget = null; + +// При открытии модалки checkout +document.getElementById('checkoutModal').addEventListener('show.bs.modal', () => { + const customer = selectedCustomer || SYSTEM_CUSTOMER; + const walletBalance = customer.wallet_balance || 0; + + // Показываем баланс кошелька + const walletDiv = document.getElementById('checkoutWalletBalance'); + if (customer.id !== SYSTEM_CUSTOMER.id) { + document.getElementById('checkoutWalletBalanceAmount').textContent = walletBalance.toFixed(2); + walletDiv.style.display = 'block'; + } else { + walletDiv.style.display = 'none'; } - - // Базовая сумма - document.getElementById('checkoutBasePrice').textContent = formatMoney(basePrice) + ' руб.'; - - // Скидка - const discountType = document.getElementById('discountType').value; - const discountValue = parseFloat(document.getElementById('discountValue').value) || 0; - - let discountedPrice = basePrice; - if (discountType !== 'none' && discountValue > 0) { - if (discountType === 'percent') { - discountedPrice = basePrice - (basePrice * discountValue / 100); - } else if (discountType === 'amount') { - discountedPrice = Math.max(0, basePrice - discountValue); + + // Вычисляем итоговую сумму + let totalAmount = 0; + cart.forEach((item) => { + totalAmount += item.qty * item.price; + }); + + document.getElementById('checkoutFinalPrice').textContent = formatMoney(totalAmount) + ' руб.'; + + // Инициализируем виджет в single mode + initPaymentWidget('single', { + order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 }, + customer: { id: customer.id, name: customer.name, wallet_balance: walletBalance } + }); +}); + +// Переключение режима оплаты +document.getElementById('singlePaymentMode').addEventListener('click', function() { + document.getElementById('singlePaymentMode').classList.add('active'); + document.getElementById('mixedPaymentMode').classList.remove('active'); + reinitPaymentWidget('single'); +}); + +document.getElementById('mixedPaymentMode').addEventListener('click', function() { + document.getElementById('mixedPaymentMode').classList.add('active'); + document.getElementById('singlePaymentMode').classList.remove('active'); + reinitPaymentWidget('mixed'); +}); + +function reinitPaymentWidget(mode) { + const customer = selectedCustomer || SYSTEM_CUSTOMER; + const totalAmountText = document.getElementById('checkoutFinalPrice').textContent; + const totalAmount = parseFloat(totalAmountText.replace(/[^\d.]/g, '')); + + initPaymentWidget(mode, { + order: { total: totalAmount, amount_due: totalAmount, amount_paid: 0 }, + customer: { id: customer.id, name: customer.name, wallet_balance: customer.wallet_balance || 0 } + }); +} + +async function initPaymentWidget(mode, data) { + const paymentMethods = [ + { id: 1, code: 'account_balance', name: 'С баланса счёта' }, + { id: 2, code: 'cash', name: 'Наличными' }, + { id: 3, code: 'card', name: 'Картой' }, + { id: 4, code: 'online', name: 'Онлайн' } + ]; + + // Динамически загружаем PaymentWidget если еще не загружен + if (!window.PaymentWidget) { + try { + const module = await import('/static/orders/js/payment_widget.js'); + window.PaymentWidget = module.PaymentWidget; + } catch (error) { + console.error('Ошибка загрузки PaymentWidget:', error); + alert('Ошибка загрузки модуля оплаты. Перезагрузите страницу.'); + return; } } - - document.getElementById('checkoutDiscountedPrice').textContent = formatMoney(discountedPrice) + ' руб.'; - - // Финальная цена (с учётом ручной суммы если задана) - const useManualPrice = document.getElementById('useManualPrice').checked; - const manualPrice = parseFloat(document.getElementById('manualPrice').value) || 0; - - let finalPrice = discountedPrice; - if (useManualPrice && manualPrice > 0) { - finalPrice = manualPrice; - } - - document.getElementById('checkoutFinalPrice').textContent = formatMoney(finalPrice); + + paymentWidget = new window.PaymentWidget({ + containerId: 'paymentWidgetContainer', + mode: mode, + order: data.order, + customer: data.customer, + paymentMethods: paymentMethods, + onSubmit: (paymentsData) => handleCheckoutSubmit(paymentsData) + }); } -// Обработчики для полей скидки и цены -document.getElementById('discountType').addEventListener('change', function() { - const discountBlock = document.getElementById('discountValueBlock'); - if (this.value === 'none') { - discountBlock.style.display = 'none'; - document.getElementById('discountValue').value = '0'; - } else { - discountBlock.style.display = 'block'; - } - updateCheckoutPricing(); -}); - -document.getElementById('discountValue').addEventListener('input', function() { - updateCheckoutPricing(); -}); - -document.getElementById('useManualPrice').addEventListener('change', function() { - const manualPriceBlock = document.getElementById('manualPriceBlock'); - if (this.checked) { - manualPriceBlock.style.display = 'block'; - } else { - manualPriceBlock.style.display = 'none'; - document.getElementById('manualPrice').value = ''; - } - updateCheckoutPricing(); -}); - -document.getElementById('manualPrice').addEventListener('input', function() { - updateCheckoutPricing(); -}); - -// Подтверждение продажи (пока заглушка) +// Обработчик кнопки "Подтвердить продажу" document.getElementById('confirmCheckoutBtn').onclick = () => { - const payment = document.getElementById('paymentMethod').value; - const note = document.getElementById('orderNote').value.trim(); - - const paymentText = { - 'cash': 'Наличные', - 'card': 'Карта', - 'mixed': 'Смешанная оплата' - }[payment] || payment; - - // Получаем данные о ценах и скидке - const basePrice = document.getElementById('checkoutBasePrice').textContent; - const discountType = document.getElementById('discountType').value; - const discountValue = document.getElementById('discountValue').value; - const finalPrice = document.getElementById('checkoutFinalPrice').textContent; - - let discountInfo = 'нет скидки'; - if (discountType === 'percent' && discountValue > 0) { - discountInfo = `скидка ${discountValue}%`; - } else if (discountType === 'amount' && discountValue > 0) { - discountInfo = `скидка ${discountValue} руб.`; + if (paymentWidget) { + paymentWidget.submit(); } - - const useManual = document.getElementById('useManualPrice').checked; - if (useManual) { - discountInfo += ' (установлена своя сумма)'; - } - - alert(`Функция проведения продажи будет подключена позже. - -Базовая сумма: ${basePrice} -Скидка: ${discountInfo} -Итого к оплате: ${finalPrice} руб. -Оплата: ${paymentText} -Комментарий: ${note || '—'}`); - - const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal')); - modal.hide(); }; +// Отправка заказа на сервер +async function handleCheckoutSubmit(paymentsData) { + try { + // Блокируем кнопку + const btn = document.getElementById('confirmCheckoutBtn'); + btn.disabled = true; + btn.innerHTML = 'Обработка...'; + + // Собираем данные + const customer = selectedCustomer || SYSTEM_CUSTOMER; + const orderData = { + customer_id: customer.id, + warehouse_id: currentWarehouse.id, + items: Array.from(cart.values()).map(item => ({ + type: item.type, + id: item.id, + quantity: item.qty, + price: item.price + })), + payments: paymentsData, + notes: document.getElementById('orderNote').value.trim() + }; + + // Отправляем на сервер + const response = await fetch('/pos/api/checkout/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify(orderData) + }); + + const result = await response.json(); + + if (result.success) { + // Успех + alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`); + + // Очищаем корзину + cart.clear(); + updateCartUI(); + updateCartCount(); + + // Закрываем модалку + const modal = bootstrap.Modal.getInstance(document.getElementById('checkoutModal')); + modal.hide(); + + // Перезагружаем витринные комплекты + loadShowcaseKits(); + + } else { + alert('Ошибка: ' + result.error); + } + + } catch (error) { + console.error('Ошибка checkout:', error); + alert('Ошибка при проведении продажи: ' + error.message); + } finally { + // Разблокируем кнопку + const btn = document.getElementById('confirmCheckoutBtn'); + btn.disabled = false; + btn.innerHTML = ' Подтвердить продажу'; + } +} + +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; +} + document.getElementById('scheduleLater').onclick = async () => { alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.'); }; diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 931d8fc..6a66598 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -274,121 +274,96 @@