Реализована полноценная система оплаты для POS-терминала

Добавлена интеграция оплаты в POS с поддержкой одиночной и смешанной оплаты,
работой с кошельком клиента и автоматическим созданием заказов.

Backend изменения:
- TransactionService: добавлены методы get_available_payment_methods() и create_multiple_payments()
  для фильтрации способов оплаты и атомарного создания нескольких платежей
- POS API: новый endpoint pos_checkout() для создания заказов со статусом "Выполнен"
  с обработкой платежей, освобождением блокировок и очисткой корзины
- Template tags: payment_tags.py для получения способов оплаты в шаблонах

Frontend изменения:
- PaymentWidget: переиспользуемый ES6 класс с поддержкой single/mixed режимов,
  автоматической валидацией и интеграцией с кошельком клиента
- terminal.html: компактное модальное окно (70vw) с оптимизированной компоновкой,
  удален функционал скидок, добавлен показ баланса кошелька
- terminal.js: динамическая загрузка PaymentWidget, интеграция с backend API,
  обработка успешной оплаты и ошибок

Поддерживаемые способы оплаты: наличные, карта, онлайн, баланс счёта.
Смешанная оплата позволяет комбинировать несколько способов в одной транзакции.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-03 15:38:35 +03:00
parent 9dab280def
commit 1cda9086d0
7 changed files with 865 additions and 193 deletions

View File

@@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
// Собираем данные
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 = '<i class="bi bi-check2-circle"></i> Подтвердить продажу';
}
}
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('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
};

View File

@@ -274,121 +274,96 @@
<!-- Модалка: Продажа -->
<div class="modal fade" id="checkoutModal" tabindex="-1" aria-labelledby="checkoutModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="modal-dialog modal-dialog-centered modal-lg" style="max-width: 70vw;">
<div class="modal-content" style="max-height: 90vh; overflow: hidden;">
<div class="modal-header py-2">
<h5 class="modal-title" id="checkoutModalLabel">
<i class="bi bi-cash-stack"></i> Продажа
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="modal-body py-2" style="overflow-y: auto;">
<div class="row g-3">
<!-- Левая колонка: состав заказа -->
<div class="col-md-7">
<!-- Информация о клиенте -->
<div class="mb-3">
<label class="form-label fw-semibold">Клиент</label>
<div class="d-flex gap-1 align-items-center">
<button class="btn btn-outline-primary btn-sm d-flex align-items-center" id="checkoutCustomerSelectBtn">
<i class="bi bi-person me-1"></i>
<div class="d-flex flex-column align-items-start lh-1">
<small class="text-muted" style="font-size: 0.65rem;">Клиент</small>
<span id="checkoutCustomerSelectBtnText" class="fw-semibold">Выбрать</span>
</div>
</button>
<button class="btn btn-sm btn-outline-danger" id="checkoutResetCustomerBtn" title="Сброс на системного клиента" style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
<!-- Информация о клиенте и баланс в одной строке -->
<div class="row g-2 mb-2">
<div class="col-auto">
<label class="form-label fw-semibold small mb-1">Клиент</label>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" id="checkoutCustomerSelectBtn" style="font-size: 0.85rem; padding: 0.25rem 0.5rem;">
<i class="bi bi-person me-1"></i>
<span id="checkoutCustomerSelectBtnText">Выбрать</span>
</button>
<button class="btn btn-sm btn-outline-danger" id="checkoutResetCustomerBtn" title="Сброс" style="display: none; font-size: 0.85rem; padding: 0.25rem 0.5rem;">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="col" id="checkoutWalletBalance" style="display: none;">
<label class="form-label fw-semibold small mb-1">Баланс кошелька</label>
<div class="alert alert-info py-1 px-2 mb-0" style="font-size: 0.9rem;">
<i class="bi bi-wallet2"></i>
<span id="checkoutWalletBalanceAmount">0.00</span> руб.
</div>
</div>
</div>
<div class="mb-3">
<strong>Состав заказа</strong>
<div class="border rounded p-3 mt-2" id="checkoutItems" style="max-height: 240px; overflow-y: auto; background: #f8f9fa;">
<!-- Состав заказа -->
<div class="mb-2">
<strong class="small">Состав заказа</strong>
<div class="border rounded p-2 mt-1" id="checkoutItems" style="max-height: 180px; overflow-y: auto; background: #f8f9fa; font-size: 0.9rem;">
<!-- Заполняется из JS -->
</div>
</div>
<!-- Опции оплаты и комментарий -->
<div class="row g-3">
<div class="col-md-6">
<label for="paymentMethod" class="form-label">Способ оплаты</label>
<select class="form-select" id="paymentMethod">
<option value="cash">Наличные</option>
<option value="card">Карта</option>
<option value="mixed">Смешанная оплата</option>
</select>
</div>
<div class="col-md-6">
<label for="orderNote" class="form-label">Комментарий</label>
<input type="text" class="form-control" id="orderNote" placeholder="Примечание к заказу">
</div>
<!-- Комментарий к заказу -->
<div class="mb-2">
<label for="orderNote" class="form-label small mb-1">Комментарий</label>
<input type="text" class="form-control form-control-sm" id="orderNote" placeholder="Примечание к заказу">
</div>
</div>
<!-- Правая колонка: ценообразование -->
<!-- Правая колонка: оплата -->
<div class="col-md-5">
<div class="card">
<div class="card-header bg-light">
<strong>Ценообразование</strong>
<div class="card mb-0">
<div class="card-header bg-light py-2">
<strong class="small">Оплата</strong>
</div>
<div class="card-body">
<!-- Базовая сумма -->
<div class="mb-2">
<small class="text-muted">Базовая сумма (корзина):</small>
<div class="fw-bold" id="checkoutBasePrice">0.00 руб.</div>
</div>
<!-- Скидка -->
<div class="mb-2">
<label for="discountType" class="form-label small">Скидка</label>
<select class="form-select form-select-sm" id="discountType">
<option value="none">Без скидки</option>
<option value="percent">Процент (%)</option>
<option value="amount">Сумма (руб.)</option>
</select>
</div>
<div class="mb-2" id="discountValueBlock" style="display: none;">
<label for="discountValue" class="form-label small">Значение скидки</label>
<input type="number" class="form-control form-control-sm" id="discountValue"
min="0" step="0.01" value="0" placeholder="0.00">
</div>
<!-- Расчётная сумма со скидкой -->
<div class="card-body p-2">
<!-- Итого к оплате -->
<div class="mb-2 pb-2 border-bottom">
<small class="text-muted">Сумма со скидкой:</small>
<div class="fw-bold text-success" id="checkoutDiscountedPrice">0.00 руб.</div>
<small class="text-muted">Итого к оплате:</small>
<div class="fw-bold text-success fs-5" id="checkoutFinalPrice">0.00 руб.</div>
</div>
<!-- Ручная финальная сумма -->
<div class="mb-0">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="useManualPrice">
<label class="form-check-label small" for="useManualPrice">
Установить свою сумму (приоритет)
</label>
</div>
<div id="manualPriceBlock" style="display: none;">
<input type="number" class="form-control form-control-sm" id="manualPrice"
min="0" step="0.01" placeholder="Введите сумму">
<!-- Переключатель режима -->
<div class="mb-2">
<label class="form-label fw-semibold small mb-1">Режим оплаты</label>
<div class="btn-group w-100" role="group">
<button type="button"
class="btn btn-sm btn-outline-primary active"
id="singlePaymentMode">
Одним способом
</button>
<button type="button"
class="btn btn-sm btn-outline-primary"
id="mixedPaymentMode">
Смешанная
</button>
</div>
</div>
<!-- Итоговая сумма продажи -->
<div class="alert alert-success mt-3 mb-0">
<strong>Итого к оплате:</strong><br>
<span class="fs-3" id="checkoutFinalPrice">0.00</span> руб.
</div>
<!-- Контейнер для PaymentWidget -->
<div id="paymentWidgetContainer"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success btn-lg" id="confirmCheckoutBtn">
<button type="button" class="btn btn-success" id="confirmCheckoutBtn">
<i class="bi bi-check2-circle"></i> Подтвердить продажу
</button>
</div>

View File

@@ -31,4 +31,6 @@ urlpatterns = [
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
# Создать временный комплект и зарезервировать на витрину [POST]
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
# Создать заказ и провести оплату в POS [POST]
path('api/checkout/', views.pos_checkout, name='pos-checkout'),
]

View File

@@ -1240,3 +1240,167 @@ def disassemble_product_kit(request, kit_id):
'success': False,
'error': f'Ошибка при разборе: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def pos_checkout(request):
"""
Создать заказ и провести оплату в POS-терминале.
Payload (JSON):
{
"customer_id": int,
"warehouse_id": int,
"items": [
{"type": "product"|"kit"|"showcase_kit", "id": int, "quantity": float, "price": float},
...
],
"payments": [
{"payment_method": "cash"|"card"|"online"|"account_balance", "amount": float, "notes": str},
...
],
"notes": str (optional)
}
"""
from orders.models import Order, OrderItem, OrderStatus
from orders.services.transaction_service import TransactionService
from customers.models import Customer
from products.models import Product, ProductKit
from inventory.models import Warehouse, Reservation
from django.db import transaction as db_transaction
from decimal import Decimal
import json
try:
body = json.loads(request.body)
# Валидация
customer_id = body.get('customer_id')
warehouse_id = body.get('warehouse_id')
items_data = body.get('items', [])
payments_data = body.get('payments', [])
order_notes = body.get('notes', '')
if not customer_id:
return JsonResponse({'success': False, 'error': 'Не указан клиент'}, status=400)
if not warehouse_id:
return JsonResponse({'success': False, 'error': 'Не указан склад'}, status=400)
if not items_data:
return JsonResponse({'success': False, 'error': 'Корзина пуста'}, status=400)
if not payments_data:
return JsonResponse({'success': False, 'error': 'Не указаны способы оплаты'}, status=400)
# Получаем объекты
customer = get_object_or_404(Customer, id=customer_id)
warehouse = get_object_or_404(Warehouse, id=warehouse_id, is_active=True)
try:
completed_status = OrderStatus.objects.get(code='completed', is_system=True)
except OrderStatus.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Статус "Выполнен" не найден в системе'
}, status=500)
# Атомарная операция
with db_transaction.atomic():
# 1. Создаём заказ
order = Order.objects.create(
customer=customer,
is_delivery=False, # POS - всегда самовывоз
pickup_warehouse=warehouse,
status=completed_status, # Сразу "Выполнен"
special_instructions=order_notes,
modified_by=request.user
)
# 2. Добавляем товары
for item_data in items_data:
item_type = item_data['type']
item_id = item_data['id']
quantity = Decimal(str(item_data['quantity']))
price = Decimal(str(item_data['price']))
if item_type == 'product':
product = Product.objects.get(id=item_id)
OrderItem.objects.create(
order=order,
product=product,
quantity=quantity,
price=price,
is_custom_price=False
)
elif item_type in ['kit', 'showcase_kit']:
kit = ProductKit.objects.get(id=item_id)
OrderItem.objects.create(
order=order,
product_kit=kit,
quantity=quantity,
price=price,
is_custom_price=False
)
# 3. Пересчитываем итоговую стоимость
order.calculate_total()
# 4. Проводим платежи
payments_list = []
for payment_data in payments_data:
payments_list.append({
'payment_method': payment_data['payment_method'],
'amount': Decimal(str(payment_data['amount'])),
'notes': payment_data.get('notes', f"Оплата POS: {payment_data['payment_method']}")
})
transactions = TransactionService.create_multiple_payments(
order=order,
payments_list=payments_list,
user=request.user
)
# 5. Обновляем статус оплаты
order.update_payment_status()
# 6. Освобождаем блокировки витринных комплектов
showcase_kit_ids = [
item_data['id'] for item_data in items_data
if item_data['type'] == 'showcase_kit'
]
if showcase_kit_ids:
Reservation.objects.filter(
product_kit_id__in=showcase_kit_ids,
locked_by_user=request.user,
status='reserved'
).update(
cart_lock_expires_at=None,
locked_by_user=None,
cart_session_id=None
)
# 7. Очищаем корзину из Redis
from django.core.cache import cache
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
cache.delete(cart_key)
return JsonResponse({
'success': True,
'order_number': order.order_number,
'order_id': order.id,
'total_amount': float(order.total_amount),
'amount_paid': float(order.amount_paid),
'amount_due': float(order.amount_due),
'payments_count': len(transactions),
'message': f'Заказ #{order.order_number} успешно создан и оплачен'
})
except (Customer.DoesNotExist, Warehouse.DoesNotExist, Product.DoesNotExist, ProductKit.DoesNotExist) as e:
return JsonResponse({'success': False, 'error': 'Объект не найден'}, status=404)
except ValidationError as e:
return JsonResponse({'success': False, 'error': str(e)}, status=400)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': 'Неверный формат JSON'}, status=400)
except Exception as e:
logger.error(f'Ошибка при проведении продажи POS: {str(e)}', exc_info=True)
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)