FIX: Добавлен баланс кошелька клиента в модальное окно продажи POS

Проблема:
- Баланс кошелька клиента не отображался в модальном окне при нажатии "ПРОДАТЬ"
- Данные о балансе не передавались из backend в frontend

Исправления:

1. pos/views.py:
   - Добавлен wallet_balance в selected_customer при загрузке из Redis
   - Добавлен wallet_balance в system_customer
   - Добавлен wallet_balance в API set_customer (Redis + response)
   - Используется json.dumps() для корректной сериализации данных клиента

2. customers/views.py:
   - Добавлен wallet_balance в API поиска клиентов (api_search_customers)
   - Добавлен wallet_balance в API создания клиента (api_create_customer)

3. pos/static/pos/js/terminal.js:
   - Обновлена функция selectCustomer() для получения walletBalance
   - Обновлены все вызовы selectCustomer() для передачи баланса
   - selectedCustomer теперь содержит wallet_balance

4. pos/templates/pos/terminal.html:
   - Используются готовые JSON-строки из backend (system_customer_json, selected_customer_json)
   - Исправлена проблема с локализацией чисел в JSON

Результат:
Баланс кошелька клиента теперь корректно отображается в модальном окне продажи

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 13:11:50 +03:00
parent 4de89fca43
commit 16234b0a1f
4 changed files with 122 additions and 79 deletions

View File

@@ -7,6 +7,7 @@ from django.db.models.functions import Greatest, Coalesce
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from user_roles.decorators import manager_or_owner_required
import phonenumbers import phonenumbers
import json import json
from decimal import Decimal from decimal import Decimal
@@ -409,6 +410,7 @@ def api_search_customers(request):
'name': customer.name, 'name': customer.name,
'phone': phone_display, 'phone': phone_display,
'email': customer.email, 'email': customer.email,
'wallet_balance': float(customer.wallet_balance),
}) })
# Если ничего не найдено, предлагаем создать нового клиента # Если ничего не найдено, предлагаем создать нового клиента
@@ -483,6 +485,7 @@ def api_create_customer(request):
'name': customer.name, 'name': customer.name,
'phone': phone_display, 'phone': phone_display,
'email': customer.email if customer.email else '', 'email': customer.email if customer.email else '',
'wallet_balance': float(customer.wallet_balance),
}, status=201) }, status=201)
else: else:
# Собираем ошибки валидации с указанием полей # Собираем ошибки валидации с указанием полей
@@ -519,13 +522,10 @@ def api_create_customer(request):
}, status=500) }, status=500)
@login_required @manager_or_owner_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def wallet_deposit(request, pk): def wallet_deposit(request, pk):
"""Пополнение кошелька клиента""" """Пополнение кошелька клиента"""
if not request.user.is_staff:
raise PermissionDenied("У вас нет прав для изменения баланса кошелька клиента.")
customer = get_object_or_404(Customer, pk=pk) customer = get_object_or_404(Customer, pk=pk)
if customer.is_system_customer: if customer.is_system_customer:
@@ -552,13 +552,10 @@ def wallet_deposit(request, pk):
return redirect('customers:customer-detail', pk=pk) return redirect('customers:customer-detail', pk=pk)
@login_required @manager_or_owner_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def wallet_withdraw(request, pk): def wallet_withdraw(request, pk):
"""Возврат / списание с кошелька клиента""" """Возврат / списание с кошелька клиента"""
if not request.user.is_staff:
raise PermissionDenied("У вас нет прав для изменения баланса кошелька клиента.")
customer = get_object_or_404(Customer, pk=pk) customer = get_object_or_404(Customer, pk=pk)
if customer.is_system_customer: if customer.is_system_customer:

View File

@@ -86,6 +86,7 @@ function formatMoney(v) {
* - Кнопку "Выбрать клиента" в корзине (показывает имя клиента) * - Кнопку "Выбрать клиента" в корзине (показывает имя клиента)
* - Кнопку "Выбрать клиента" в модалке продажи (показывает имя клиента) * - Кнопку "Выбрать клиента" в модалке продажи (показывает имя клиента)
* - Видимость кнопок сброса в обоих местах (показываем только для не-системного клиента) * - Видимость кнопок сброса в обоих местах (показываем только для не-системного клиента)
* - Ссылку на анкету клиента (показываем только для не-системного клиента)
*/ */
function updateCustomerDisplay() { function updateCustomerDisplay() {
// Обновляем текст кнопки в корзине // Обновляем текст кнопки в корзине
@@ -110,17 +111,30 @@ function updateCustomerDisplay() {
resetBtn.style.display = isSystemCustomer ? 'none' : 'block'; resetBtn.style.display = isSystemCustomer ? 'none' : 'block';
} }
}); });
// Обновляем ссылку на анкету клиента
const profileLink = document.getElementById('customerProfileLink');
if (profileLink) {
if (isSystemCustomer) {
profileLink.style.display = 'none';
} else {
profileLink.href = `/customers/${selectedCustomer.id}/`;
profileLink.style.display = 'block';
}
}
} }
/** /**
* Устанавливает нового клиента и сохраняет в Redis * Устанавливает нового клиента и сохраняет в Redis
* @param {number} customerId - ID клиента * @param {number} customerId - ID клиента
* @param {string} customerName - Имя клиента * @param {string} customerName - Имя клиента
* @param {number} walletBalance - Баланс кошелька клиента (опционально)
*/ */
function selectCustomer(customerId, customerName) { function selectCustomer(customerId, customerName, walletBalance = 0) {
selectedCustomer = { selectedCustomer = {
id: customerId, id: customerId,
name: customerName name: customerName,
wallet_balance: walletBalance
}; };
updateCustomerDisplay(); updateCustomerDisplay();
@@ -136,6 +150,9 @@ function selectCustomer(customerId, customerName) {
.then(data => { .then(data => {
if (!data.success) { if (!data.success) {
console.error('Ошибка сохранения клиента:', data.error); console.error('Ошибка сохранения клиента:', data.error);
} else {
// Обновляем баланс из ответа сервера
selectedCustomer.wallet_balance = data.wallet_balance || 0;
} }
}) })
.catch(error => { .catch(error => {
@@ -188,8 +205,8 @@ function initCustomerSelect2() {
return; return;
} }
// Выбираем клиента // Выбираем клиента с балансом
selectCustomer(parseInt(data.id), data.name); selectCustomer(parseInt(data.id), data.name, data.wallet_balance || 0);
// Закрываем модалку // Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('selectCustomerModal'));
@@ -297,8 +314,8 @@ async function createNewCustomer() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
// Выбираем созданного клиента // Выбираем созданного клиента с балансом
selectCustomer(data.id, data.name); selectCustomer(data.id, data.name, data.wallet_balance || 0);
// Закрываем модалку // Закрываем модалку
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
@@ -516,11 +533,16 @@ function renderProducts() {
stock.style.color = '#856404'; stock.style.color = '#856404';
stock.style.fontWeight = 'bold'; stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
// Для обычных товаров показываем остатки: FREE(-RESERVED) // Для обычных товаров показываем остатки: FREE(-RESERVED-IN_CART)
// FREE = доступно для продажи (available - reserved) // FREE = доступно для продажи (available - reserved - в корзине)
const available = parseFloat(item.available_qty) || 0; const available = parseFloat(item.available_qty) || 0;
const reserved = parseFloat(item.reserved_qty) || 0; const reserved = parseFloat(item.reserved_qty) || 0;
const free = available - reserved;
// Вычитаем количество из корзины для визуализации
const cartKey = `product-${item.id}`;
const inCart = cart.has(cartKey) ? cart.get(cartKey).qty : 0;
const free = available - reserved - inCart;
// Создаём элементы для стилизации разных размеров // Создаём элементы для стилизации разных размеров
const freeSpan = document.createElement('span'); const freeSpan = document.createElement('span');
@@ -529,16 +551,24 @@ function renderProducts() {
freeSpan.style.fontWeight = 'bold'; freeSpan.style.fontWeight = 'bold';
freeSpan.style.fontStyle = 'normal'; freeSpan.style.fontStyle = 'normal';
// Отображаем резерв только если он есть // Отображаем резерв и корзину если они есть
const suffixParts = [];
if (reserved > 0) { if (reserved > 0) {
const reservedSpan = document.createElement('span'); suffixParts.push(`${reserved}`);
reservedSpan.textContent = `(${reserved})`; }
reservedSpan.style.fontSize = '0.85em'; if (inCart > 0) {
reservedSpan.style.marginLeft = '3px'; suffixParts.push(`${inCart}🛒`);
reservedSpan.style.fontStyle = 'normal'; }
if (suffixParts.length > 0) {
const suffixSpan = document.createElement('span');
suffixSpan.textContent = `(${suffixParts.join(' ')})`;
suffixSpan.style.fontSize = '0.85em';
suffixSpan.style.marginLeft = '3px';
suffixSpan.style.fontStyle = 'normal';
stock.appendChild(freeSpan); stock.appendChild(freeSpan);
stock.appendChild(reservedSpan); stock.appendChild(suffixSpan);
} else { } else {
stock.appendChild(freeSpan); stock.appendChild(freeSpan);
} }
@@ -721,6 +751,11 @@ async function addToCart(item) {
renderCart(); renderCart();
saveCartToRedis(); // Сохраняем в Redis saveCartToRedis(); // Сохраняем в Redis
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item.type === 'product') {
renderProducts();
}
// Автоматический фокус на поле количества (только для обычных товаров) // Автоматический фокус на поле количества (только для обычных товаров)
if (item.type !== 'showcase_kit') { if (item.type !== 'showcase_kit') {
setTimeout(() => { setTimeout(() => {
@@ -735,6 +770,25 @@ async function addToCart(item) {
} }
} }
// Вспомогательная функция для обновления количества товара в корзине
async function updateCartItemQty(cartKey, newQty) {
const item = cart.get(cartKey);
if (!item) return;
if (newQty <= 0) {
await removeFromCart(cartKey);
} else {
item.qty = newQty;
renderCart();
saveCartToRedis();
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item.type === 'product') {
renderProducts();
}
}
}
function renderCart() { function renderCart() {
const list = document.getElementById('cartList'); const list = document.getElementById('cartList');
list.innerHTML = ''; list.innerHTML = '';
@@ -799,16 +853,10 @@ function renderCart() {
const minusBtn = document.createElement('button'); const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-outline-secondary btn-sm'; minusBtn.className = 'btn btn-outline-secondary btn-sm';
minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>'; minusBtn.innerHTML = '<i class="bi bi-dash-circle" style="font-size: 1.2em;"></i>';
minusBtn.onclick = (e) => { minusBtn.onclick = async (e) => {
e.preventDefault(); e.preventDefault();
const currentQty = cart.get(cartKey).qty; const currentQty = cart.get(cartKey).qty;
if (currentQty <= 1) { await updateCartItemQty(cartKey, currentQty - 1);
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = currentQty - 1;
renderCart();
saveCartToRedis();
}
}; };
// Поле ввода количества // Поле ввода количества
@@ -820,26 +868,19 @@ function renderCart() {
qtyInput.style.padding = '0.375rem 0.25rem'; qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty; qtyInput.value = item.qty;
qtyInput.min = 1; qtyInput.min = 1;
qtyInput.onchange = (e) => { qtyInput.onchange = async (e) => {
const newQty = parseInt(e.target.value) || 1; const newQty = parseInt(e.target.value) || 1;
if (newQty <= 0) { await updateCartItemQty(cartKey, newQty);
removeFromCart(cartKey);
} else {
cart.get(cartKey).qty = newQty;
renderCart();
saveCartToRedis(); // Сохраняем в Redis при изменении количества
}
}; };
// Кнопка плюс // Кнопка плюс
const plusBtn = document.createElement('button'); const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-outline-secondary btn-sm'; plusBtn.className = 'btn btn-outline-secondary btn-sm';
plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>'; plusBtn.innerHTML = '<i class="bi bi-plus-circle" style="font-size: 1.2em;"></i>';
plusBtn.onclick = (e) => { plusBtn.onclick = async (e) => {
e.preventDefault(); e.preventDefault();
cart.get(cartKey).qty += 1; const currentQty = cart.get(cartKey).qty;
renderCart(); await updateCartItemQty(cartKey, currentQty + 1);
saveCartToRedis();
}; };
// Собираем контейнер // Собираем контейнер
@@ -908,6 +949,11 @@ async function removeFromCart(cartKey) {
cart.delete(cartKey); cart.delete(cartKey);
renderCart(); renderCart();
saveCartToRedis(); // Сохраняем в Redis saveCartToRedis(); // Сохраняем в Redis
// Перерисовываем товары для обновления визуального остатка
if (!isShowcaseView && item && item.type === 'product') {
renderProducts();
}
} }
function clearCart() { function clearCart() {

View File

@@ -69,6 +69,9 @@
<span id="customerSelectBtnText" class="fw-semibold">Выбрать</span> <span id="customerSelectBtnText" class="fw-semibold">Выбрать</span>
</div> </div>
</button> </button>
<a href="#" id="customerProfileLink" class="btn btn-sm btn-outline-secondary" title="Открыть анкету клиента" target="_blank" style="display: none;">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<button class="btn btn-sm btn-outline-danger" id="resetCustomerBtn" title="Сбросить на системного клиента" style="display: none;"> <button class="btn btn-sm btn-outline-danger" id="resetCustomerBtn" title="Сбросить на системного клиента" style="display: none;">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
@@ -484,18 +487,8 @@
<script id="categoriesData" type="application/json">{{ categories_json|safe }}</script> <script id="categoriesData" type="application/json">{{ categories_json|safe }}</script>
<script id="itemsData" type="application/json">{{ items_json|safe }}</script> <script id="itemsData" type="application/json">{{ items_json|safe }}</script>
<script id="showcaseKitsData" type="application/json">{{ showcase_kits_json|safe }}</script> <script id="showcaseKitsData" type="application/json">{{ showcase_kits_json|safe }}</script>
<script id="systemCustomerData" type="application/json"> <script id="systemCustomerData" type="application/json">{{ system_customer_json|safe }}</script>
{ <script id="selectedCustomerData" type="application/json">{{ selected_customer_json|safe }}</script>
"id": {{ system_customer.id }},
"name": "{{ system_customer.name|escapejs }}"
}
</script>
<script id="selectedCustomerData" type="application/json">
{
"id": {{ selected_customer.id }},
"name": "{{ selected_customer.name|escapejs }}"
}
</script>
<script id="cartData" type="application/json">{{ cart_data|safe }}</script> <script id="cartData" type="application/json">{{ cart_data|safe }}</script>
<script id="currentWarehouseData" type="application/json"> <script id="currentWarehouseData" type="application/json">
{ {

View File

@@ -181,14 +181,16 @@ def pos_terminal(request):
'showcase_kits_json': json.dumps([]), 'showcase_kits_json': json.dumps([]),
'current_warehouse': None, 'current_warehouse': None,
'warehouses': [], 'warehouses': [],
'system_customer': { 'system_customer_json': json.dumps({
'id': system_customer.id, 'id': system_customer.id,
'name': system_customer.name 'name': system_customer.name,
}, 'wallet_balance': float(system_customer.wallet_balance)
'selected_customer': { }),
'selected_customer_json': json.dumps({
'id': system_customer.id, 'id': system_customer.id,
'name': system_customer.name 'name': system_customer.name,
}, 'wallet_balance': float(system_customer.wallet_balance)
}),
'cart_data': json.dumps({}), 'cart_data': json.dumps({}),
'title': 'POS Terminal', 'title': 'POS Terminal',
} }
@@ -208,7 +210,8 @@ def pos_terminal(request):
customer = Customer.objects.get(id=cached_customer_data['customer_id']) customer = Customer.objects.get(id=cached_customer_data['customer_id'])
selected_customer = { selected_customer = {
'id': customer.id, 'id': customer.id,
'name': customer.name 'name': customer.name,
'wallet_balance': float(customer.wallet_balance)
} }
except Customer.DoesNotExist: except Customer.DoesNotExist:
# Клиент был удален - очищаем кэш # Клиент был удален - очищаем кэш
@@ -218,7 +221,8 @@ def pos_terminal(request):
if not selected_customer: if not selected_customer:
selected_customer = { selected_customer = {
'id': system_customer.id, 'id': system_customer.id,
'name': system_customer.name 'name': system_customer.name,
'wallet_balance': float(system_customer.wallet_balance)
} }
# Пытаемся получить сохраненную корзину из Redis # Пытаемся получить сохраненную корзину из Redis
@@ -280,11 +284,12 @@ def pos_terminal(request):
'name': current_warehouse.name 'name': current_warehouse.name
}, },
'warehouses': warehouses_list, 'warehouses': warehouses_list,
'system_customer': { 'system_customer_json': json.dumps({
'id': system_customer.id, 'id': system_customer.id,
'name': system_customer.name 'name': system_customer.name,
}, 'wallet_balance': float(system_customer.wallet_balance)
'selected_customer': selected_customer, # Текущий выбранный клиент (из Redis или системный) }),
'selected_customer_json': json.dumps(selected_customer), # Текущий выбранный клиент (из Redis или системный)
'cart_data': json.dumps(cart_data), # Сохраненная корзина из Redis 'cart_data': json.dumps(cart_data), # Сохраненная корзина из Redis
'title': 'POS Terminal', 'title': 'POS Terminal',
} }
@@ -355,14 +360,16 @@ def set_customer(request, customer_id):
redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}' redis_key = f'pos:customer:{request.user.id}:{current_warehouse.id}'
customer_data = { customer_data = {
'customer_id': customer.id, 'customer_id': customer.id,
'customer_name': customer.name 'customer_name': customer.name,
'wallet_balance': float(customer.wallet_balance)
} }
cache.set(redis_key, customer_data, timeout=7200) # 2 часа cache.set(redis_key, customer_data, timeout=7200) # 2 часа
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'customer_id': customer.id, 'customer_id': customer.id,
'customer_name': customer.name 'customer_name': customer.name,
'wallet_balance': float(customer.wallet_balance)
}) })