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