Исправлена ошибка создания заказов в POS после рефакторинга модели доставки
- Обновлён pos/views.py: метод pos_checkout теперь создаёт Order и связанную модель Delivery - Обновлён showcase_manager.py: метод sell_showcase_item_to_customer использует новую архитектуру - Удалён устаревший скрипт create_demo_orders.py - Исправлена ошибка 'property is_delivery of Order object has no setter'
This commit is contained in:
@@ -274,12 +274,25 @@ class ShowcaseManager:
|
||||
# Создаём заказ
|
||||
order = Order.objects.create(
|
||||
customer=customer,
|
||||
is_delivery=False,
|
||||
pickup_warehouse=warehouse,
|
||||
status=completed_status,
|
||||
is_paid=True,
|
||||
modified_by=user
|
||||
)
|
||||
|
||||
# Создаём доставку (самовывоз)
|
||||
from orders.models import Delivery
|
||||
from django.utils import timezone as tz
|
||||
now_local = tz.localtime(tz.now())
|
||||
|
||||
Delivery.objects.create(
|
||||
order=order,
|
||||
delivery_type=Delivery.DELIVERY_TYPE_PICKUP,
|
||||
pickup_warehouse=warehouse,
|
||||
delivery_date=now_local.date(),
|
||||
time_from=now_local.time(),
|
||||
time_to=now_local.time(),
|
||||
cost=0
|
||||
)
|
||||
|
||||
# Определяем цену
|
||||
price = custom_price if custom_price else product_kit.actual_price
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"""
|
||||
Management команда для создания демо-заказов на разные даты
|
||||
ВАЖНО: Создает заказы через Django ORM, что автоматически активирует
|
||||
сигналы резервирования товаров!
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.db import connection
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
from decimal import Decimal
|
||||
|
||||
from orders.models import Order, OrderItem, Address, Recipient
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создает демо-заказы через ORM (с автоматическим резервированием товаров)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--count',
|
||||
type=int,
|
||||
default=25,
|
||||
help='Количество заказов для создания (по умолчанию: 25)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--schema',
|
||||
type=str,
|
||||
default='grach',
|
||||
help='Схема базы данных (tenant) для создания заказов'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
count = options['count']
|
||||
schema_name = options['schema']
|
||||
|
||||
# Устанавливаем схему для работы с tenant
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f'SET search_path TO {schema_name}')
|
||||
|
||||
self.stdout.write(f'[НАЧАЛО] Создание {count} демо-заказов в схеме {schema_name}...')
|
||||
self.stdout.write('[INFO] Заказы создаются через ORM - резервы товаров будут созданы автоматически!')
|
||||
|
||||
# Проверяем наличие необходимых данных
|
||||
customers = list(Customer.objects.all())
|
||||
if not customers:
|
||||
self.stdout.write(self.style.ERROR('Нет клиентов в базе! Создайте хотя бы одного клиента.'))
|
||||
return
|
||||
|
||||
products = list(Product.objects.all())
|
||||
if not products:
|
||||
self.stdout.write(self.style.ERROR('Нет товаров в базе! Создайте хотя бы один товар.'))
|
||||
return
|
||||
|
||||
addresses = list(Address.objects.all())
|
||||
warehouses = list(Warehouse.objects.filter(is_pickup_point=True))
|
||||
|
||||
if not addresses and not warehouses:
|
||||
self.stdout.write(self.style.ERROR('Нет ни адресов, ни складов для самовывоза! Создайте хотя бы что-то одно.'))
|
||||
return
|
||||
|
||||
# Статусы и их вероятности
|
||||
statuses = [
|
||||
('new', 0.15),
|
||||
('confirmed', 0.25),
|
||||
('in_assembly', 0.20),
|
||||
('in_delivery', 0.15),
|
||||
('delivered', 0.20),
|
||||
('cancelled', 0.05),
|
||||
]
|
||||
|
||||
payment_statuses = [
|
||||
('unpaid', 0.30),
|
||||
('partial', 0.20),
|
||||
('paid', 0.50),
|
||||
]
|
||||
|
||||
payment_methods = [
|
||||
'cash_to_courier',
|
||||
'card_to_courier',
|
||||
'online',
|
||||
'bank_transfer',
|
||||
]
|
||||
|
||||
# Генерируем даты в диапазоне ±15 дней от сегодня
|
||||
today = datetime.now().date()
|
||||
|
||||
created_count = 0
|
||||
for i in range(count):
|
||||
try:
|
||||
# Случайная дата доставки
|
||||
days_offset = random.randint(-15, 15)
|
||||
delivery_date = today + timedelta(days=days_offset)
|
||||
|
||||
# Выбираем клиента
|
||||
customer = random.choice(customers)
|
||||
|
||||
# Выбираем тип доставки
|
||||
is_delivery = random.choice([True, False]) if addresses and shops else bool(addresses)
|
||||
|
||||
# Создаем заказ
|
||||
order = Order()
|
||||
order.customer = customer
|
||||
order.is_delivery = is_delivery
|
||||
|
||||
# Устанавливаем адрес или магазин
|
||||
if is_delivery and addresses:
|
||||
# Для доставки выбираем случайный адрес (адреса теперь привязаны к заказам)
|
||||
order.delivery_address = random.choice(addresses)
|
||||
order.delivery_cost = Decimal(random.randint(200, 500))
|
||||
elif warehouses:
|
||||
order.pickup_warehouse = random.choice(warehouses)
|
||||
order.delivery_cost = Decimal(0)
|
||||
|
||||
# Дата и время
|
||||
order.delivery_date = delivery_date
|
||||
if random.random() > 0.3: # 70% заказов с указанным временем
|
||||
start_hour = random.randint(9, 18)
|
||||
order.delivery_time_start = f"{start_hour:02d}:00:00"
|
||||
order.delivery_time_end = f"{start_hour + 2:02d}:00:00"
|
||||
|
||||
# Статус
|
||||
status_choices = [s[0] for s in statuses]
|
||||
status_weights = [s[1] for s in statuses]
|
||||
order.status = random.choices(status_choices, weights=status_weights)[0]
|
||||
|
||||
# Способ оплаты
|
||||
order.payment_method = random.choice(payment_methods)
|
||||
|
||||
# Дополнительная информация
|
||||
if random.random() > 0.7: # 30% - подарок другому человеку
|
||||
# Создаем получателя
|
||||
recipient_name = f"Получатель {i+1}"
|
||||
recipient_phone = f"+7{random.randint(9000000000, 9999999999)}"
|
||||
recipient, created = Recipient.objects.get_or_create(
|
||||
name=recipient_name,
|
||||
phone=recipient_phone
|
||||
)
|
||||
order.recipient = recipient
|
||||
|
||||
if random.random() > 0.8: # 20% анонимных
|
||||
order.is_anonymous = True
|
||||
|
||||
if random.random() > 0.5: # 50% с комментариями
|
||||
comments = [
|
||||
"Позвонить за час до доставки",
|
||||
"Доставить точно в указанное время",
|
||||
"Не звонить в дверь, только по телефону",
|
||||
"Упаковать покрасивее",
|
||||
"Приложить открытку",
|
||||
]
|
||||
order.special_instructions = random.choice(comments)
|
||||
|
||||
# Сохраняем заказ (чтобы получить ID)
|
||||
order.save()
|
||||
|
||||
# Добавляем товары в заказ
|
||||
items_count = random.randint(1, 4)
|
||||
order_products = random.sample(products, min(items_count, len(products)))
|
||||
|
||||
items_total = Decimal(0)
|
||||
for product in order_products:
|
||||
item = OrderItem()
|
||||
item.order = order
|
||||
item.product = product
|
||||
item.quantity = random.randint(1, 3)
|
||||
item.price = product.price
|
||||
item.save()
|
||||
items_total += item.get_total_price()
|
||||
|
||||
# Рассчитываем итоговую сумму
|
||||
order.total_amount = items_total + order.delivery_cost
|
||||
|
||||
# Скидка (20% заказов)
|
||||
if random.random() > 0.8:
|
||||
order.discount_amount = Decimal(random.randint(100, 500))
|
||||
order.total_amount -= order.discount_amount
|
||||
|
||||
# Статус оплаты
|
||||
payment_status_choices = [s[0] for s in payment_statuses]
|
||||
payment_status_weights = [s[1] for s in payment_statuses]
|
||||
order.payment_status = random.choices(payment_status_choices, weights=payment_status_weights)[0]
|
||||
|
||||
if order.payment_status == 'paid':
|
||||
order.amount_paid = order.total_amount
|
||||
order.is_paid = True
|
||||
elif order.payment_status == 'partial':
|
||||
order.amount_paid = order.total_amount * Decimal(random.uniform(0.2, 0.8))
|
||||
order.is_paid = False
|
||||
else:
|
||||
order.amount_paid = Decimal(0)
|
||||
order.is_paid = False
|
||||
|
||||
order.save()
|
||||
|
||||
created_count += 1
|
||||
self.stdout.write(f' [OK] Заказ #{order.order_number} на {delivery_date} (товаров: {len(order_products)})')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'[ОШИБКА] Заказ {i+1}: {str(e)}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n[ЗАВЕРШЕНО] Успешно создано {created_count} заказов!'))
|
||||
self.stdout.write(f'Даты доставки: от {today - timedelta(days=15)} до {today + timedelta(days=15)}')
|
||||
self.stdout.write(self.style.SUCCESS('\n[ВАЖНО] Резервы товаров созданы автоматически через Django сигналы!'))
|
||||
@@ -356,3 +356,70 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для модального окна выбора единицы продажи */
|
||||
.unit-selection-card {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.unit-selection-card:hover {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.unit-selection-card.selected {
|
||||
border-color: #0d6efd;
|
||||
background: #e7f3ff;
|
||||
box-shadow: 0 2px 12px rgba(13,110,253,0.3);
|
||||
}
|
||||
|
||||
.unit-selection-card .unit-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.unit-selection-card .unit-price {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #0d6efd;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.unit-selection-card .unit-availability {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.unit-selection-card .unit-code {
|
||||
font-size: 0.8rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.unit-selection-card .badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Индикаторы наличия */
|
||||
.stock-badge-good {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.stock-badge-low {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.stock-badge-none {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ let editingKitId = null;
|
||||
// Временная корзина для модального окна создания/редактирования комплекта
|
||||
const tempCart = new Map();
|
||||
|
||||
// ===== ПЕРЕМЕННЫЕ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
|
||||
let unitModalProduct = null; // Текущий товар для модального окна
|
||||
let unitModalSalesUnits = []; // Список единиц продажи
|
||||
let selectedSalesUnit = null; // Выбранная единица продажи
|
||||
let unitModalInstance = null; // Bootstrap Modal instance
|
||||
|
||||
// ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS =====
|
||||
|
||||
let saveCartTimeout = null;
|
||||
@@ -390,6 +396,255 @@ async function createNewCustomer() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ФУНКЦИИ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
|
||||
|
||||
/**
|
||||
* Открывает модальное окно выбора единицы продажи
|
||||
* @param {object} product - Объект товара с информацией о единицах продажи
|
||||
*/
|
||||
async function openProductUnitModal(product) {
|
||||
unitModalProduct = product;
|
||||
|
||||
// Устанавливаем название товара
|
||||
document.getElementById('unitModalProductName').textContent =
|
||||
`${product.name}${product.sku ? ' (' + product.sku + ')' : ''}`;
|
||||
|
||||
// Загружаем единицы продажи
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/products/api/products/${product.id}/sales-units/?warehouse=${currentWarehouse.id}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.sales_units || data.sales_units.length === 0) {
|
||||
alert('Не удалось загрузить единицы продажи');
|
||||
return;
|
||||
}
|
||||
|
||||
unitModalSalesUnits = data.sales_units;
|
||||
|
||||
// Отрисовываем список единиц
|
||||
renderUnitSelectionList();
|
||||
|
||||
// Выбираем единицу по умолчанию или первую
|
||||
const defaultUnit = unitModalSalesUnits.find(u => u.is_default) || unitModalSalesUnits[0];
|
||||
if (defaultUnit) {
|
||||
selectUnit(defaultUnit);
|
||||
}
|
||||
|
||||
// Открываем модальное окно
|
||||
if (!unitModalInstance) {
|
||||
unitModalInstance = new bootstrap.Modal(document.getElementById('selectProductUnitModal'));
|
||||
}
|
||||
unitModalInstance.show();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки единиц продажи:', error);
|
||||
alert('Ошибка загрузки данных. Попробуйте ещё раз.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовывает список единиц продажи
|
||||
*/
|
||||
function renderUnitSelectionList() {
|
||||
const listContainer = document.getElementById('unitSelectionList');
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
unitModalSalesUnits.forEach(unit => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'unit-selection-card';
|
||||
card.dataset.unitId = unit.id;
|
||||
card.onclick = () => selectUnit(unit);
|
||||
|
||||
// Доступное количество
|
||||
const availableQty = parseFloat(unit.available_quantity || 0);
|
||||
let stockBadgeClass = 'stock-badge-none';
|
||||
let stockText = 'Нет на складе';
|
||||
|
||||
if (availableQty > 10) {
|
||||
stockBadgeClass = 'stock-badge-good';
|
||||
stockText = `${availableQty} ${unit.unit_short_name} доступно`;
|
||||
} else if (availableQty > 0) {
|
||||
stockBadgeClass = 'stock-badge-low';
|
||||
stockText = `${availableQty} ${unit.unit_short_name} доступно`;
|
||||
}
|
||||
|
||||
// Бейдж "По умолчанию"
|
||||
const defaultBadge = unit.is_default ?
|
||||
'<span class="badge bg-primary ms-2">По умолчанию</span>' : '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="unit-name">${unit.name}${defaultBadge}</div>
|
||||
<div class="unit-code text-muted">${unit.unit_code} (${unit.unit_short_name})</div>
|
||||
</div>
|
||||
<div class="unit-price">${formatMoney(unit.actual_price)} руб</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge ${stockBadgeClass}">${stockText}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
listContainer.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает единицу продажи
|
||||
* @param {object} unit - Объект единицы продажи
|
||||
*/
|
||||
function selectUnit(unit) {
|
||||
selectedSalesUnit = unit;
|
||||
|
||||
// Обновляем визуальное выделение
|
||||
document.querySelectorAll('.unit-selection-card').forEach(card => {
|
||||
if (card.dataset.unitId === String(unit.id)) {
|
||||
card.classList.add('selected');
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем отображение выбранной единицы
|
||||
document.getElementById('selectedUnitDisplay').textContent =
|
||||
`${unit.name} (${unit.unit_short_name})`;
|
||||
|
||||
// Устанавливаем минимальное количество и шаг
|
||||
const qtyInput = document.getElementById('unitModalQuantity');
|
||||
qtyInput.value = roundQuantity(unit.min_quantity, 3);
|
||||
qtyInput.min = unit.min_quantity;
|
||||
qtyInput.step = unit.quantity_step;
|
||||
|
||||
// Устанавливаем цену
|
||||
document.getElementById('unitModalPrice').value = unit.actual_price;
|
||||
|
||||
// Обновляем подсказку
|
||||
const hintEl = document.getElementById('unitQtyHint');
|
||||
hintEl.textContent = `Мин. ${unit.min_quantity}, шаг ${unit.quantity_step}`;
|
||||
|
||||
// Сбрасываем индикатор изменения цены
|
||||
document.getElementById('priceOverrideIndicator').style.display = 'none';
|
||||
|
||||
// Пересчитываем итого
|
||||
calculateUnitModalSubtotal();
|
||||
|
||||
// Валидируем количество
|
||||
validateUnitQuantity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет количество на соответствие ограничениям
|
||||
* @returns {boolean} - true если валидно
|
||||
*/
|
||||
function validateUnitQuantity() {
|
||||
if (!selectedSalesUnit) return false;
|
||||
|
||||
const qtyInput = document.getElementById('unitModalQuantity');
|
||||
const qty = parseFloat(qtyInput.value);
|
||||
const errorEl = document.getElementById('unitQtyError');
|
||||
const confirmBtn = document.getElementById('confirmAddUnitToCart');
|
||||
|
||||
// Проверка минимального количества
|
||||
if (qty < parseFloat(selectedSalesUnit.min_quantity)) {
|
||||
errorEl.textContent = `Минимальное количество: ${selectedSalesUnit.min_quantity}`;
|
||||
errorEl.style.display = 'block';
|
||||
confirmBtn.disabled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверка шага (с учётом погрешности)
|
||||
const step = parseFloat(selectedSalesUnit.quantity_step);
|
||||
const minQty = parseFloat(selectedSalesUnit.min_quantity);
|
||||
const diff = qty - minQty;
|
||||
const remainder = diff % step;
|
||||
const epsilon = 0.0001;
|
||||
|
||||
if (remainder > epsilon && (step - remainder) > epsilon) {
|
||||
errorEl.textContent = `Количество должно быть кратно ${step}`;
|
||||
errorEl.style.display = 'block';
|
||||
confirmBtn.disabled = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Всё ок, скрываем ошибку
|
||||
errorEl.style.display = 'none';
|
||||
confirmBtn.disabled = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает итоговую сумму
|
||||
*/
|
||||
function calculateUnitModalSubtotal() {
|
||||
const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value) || 0;
|
||||
const qty = roundQuantity(qtyRaw, 3); // Округляем количество
|
||||
const price = parseFloat(document.getElementById('unitModalPrice').value) || 0;
|
||||
// Округляем до 2 знаков после запятой для корректного отображения
|
||||
const subtotal = Math.round(qty * price * 100) / 100;
|
||||
|
||||
document.getElementById('unitModalSubtotal').textContent = `${formatMoney(subtotal)} руб`;
|
||||
|
||||
// Проверяем изменение цены
|
||||
if (selectedSalesUnit && Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01) {
|
||||
document.getElementById('priceOverrideIndicator').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('priceOverrideIndicator').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет товар с выбранной единицей в корзину
|
||||
*/
|
||||
function addToCartFromModal() {
|
||||
if (!validateUnitQuantity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qtyRaw = parseFloat(document.getElementById('unitModalQuantity').value);
|
||||
const qty = roundQuantity(qtyRaw, 3); // Округляем количество
|
||||
const price = parseFloat(document.getElementById('unitModalPrice').value);
|
||||
const priceOverridden = Math.abs(price - parseFloat(selectedSalesUnit.actual_price)) > 0.01;
|
||||
|
||||
// Формируем ключ корзины: product-{id}-{sales_unit_id}
|
||||
const cartKey = `product-${unitModalProduct.id}-${selectedSalesUnit.id}`;
|
||||
|
||||
// Добавляем или обновляем в корзине
|
||||
if (cart.has(cartKey)) {
|
||||
const existing = cart.get(cartKey);
|
||||
existing.qty = roundQuantity(existing.qty + qty, 3); // Округляем сумму
|
||||
existing.price = price; // Обновляем цену
|
||||
existing.quantity_step = parseFloat(selectedSalesUnit.quantity_step) || 1; // Обновляем шаг
|
||||
existing.price_overridden = priceOverridden;
|
||||
} else {
|
||||
cart.set(cartKey, {
|
||||
id: unitModalProduct.id,
|
||||
name: unitModalProduct.name,
|
||||
price: price,
|
||||
qty: qty,
|
||||
type: 'product',
|
||||
sales_unit_id: selectedSalesUnit.id,
|
||||
unit_name: selectedSalesUnit.name,
|
||||
unit_short_name: selectedSalesUnit.unit_short_name,
|
||||
quantity_step: parseFloat(selectedSalesUnit.quantity_step) || 1, // Сохраняем шаг количества
|
||||
price_overridden: priceOverridden
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем корзину
|
||||
renderCart();
|
||||
saveCartToRedis();
|
||||
|
||||
// Перерисовываем товары для обновления визуального остатка
|
||||
if (!isShowcaseView) {
|
||||
renderProducts();
|
||||
}
|
||||
|
||||
// Закрываем модальное окно
|
||||
unitModalInstance.hide();
|
||||
}
|
||||
|
||||
function renderCategories() {
|
||||
const grid = document.getElementById('categoryGrid');
|
||||
grid.innerHTML = '';
|
||||
@@ -755,6 +1010,13 @@ function setupInfiniteScroll() {
|
||||
}
|
||||
|
||||
async function addToCart(item) {
|
||||
// ПРОВЕРКА НА НАЛИЧИЕ НЕСКОЛЬКИХ ЕДИНИЦ ПРОДАЖИ
|
||||
if (item.type === 'product' && item.sales_units_count > 1) {
|
||||
// Открываем модальное окно выбора единицы
|
||||
await openProductUnitModal(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
|
||||
|
||||
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
|
||||
@@ -819,7 +1081,8 @@ async function addToCart(item) {
|
||||
if (!cart.has(cartKey)) {
|
||||
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
|
||||
} else {
|
||||
cart.get(cartKey).qty += 1;
|
||||
const cartItem = cart.get(cartKey);
|
||||
cartItem.qty = roundQuantity(cartItem.qty + 1, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,10 +1113,13 @@ async function updateCartItemQty(cartKey, newQty) {
|
||||
const item = cart.get(cartKey);
|
||||
if (!item) return;
|
||||
|
||||
if (newQty <= 0) {
|
||||
// Округляем новое количество
|
||||
const roundedQty = roundQuantity(newQty, 3);
|
||||
|
||||
if (roundedQty <= 0) {
|
||||
await removeFromCart(cartKey);
|
||||
} else {
|
||||
item.qty = newQty;
|
||||
item.qty = roundedQty;
|
||||
renderCart();
|
||||
saveCartToRedis();
|
||||
|
||||
@@ -898,10 +1164,16 @@ function renderCart() {
|
||||
if (item.type === 'kit' || item.type === 'showcase_kit') {
|
||||
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> ';
|
||||
}
|
||||
|
||||
// Единица продажи (если есть)
|
||||
let unitInfo = '';
|
||||
if (item.sales_unit_id && item.unit_name) {
|
||||
unitInfo = ` <span class="badge bg-secondary" style="font-size: 0.7rem;">${item.unit_name}</span>`;
|
||||
}
|
||||
|
||||
namePrice.innerHTML = `
|
||||
<div class="fw-semibold small">${typeIcon}${item.name}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</div>
|
||||
<div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / ${item.unit_short_name || 'шт'}</div>
|
||||
`;
|
||||
|
||||
// Знак умножения
|
||||
@@ -932,7 +1204,7 @@ function renderCart() {
|
||||
qtyInput.style.width = '60px';
|
||||
qtyInput.style.textAlign = 'center';
|
||||
qtyInput.style.padding = '0.375rem 0.25rem';
|
||||
qtyInput.value = item.qty;
|
||||
qtyInput.value = roundQuantity(item.qty, 3);
|
||||
qtyInput.min = 1;
|
||||
qtyInput.readOnly = true; // Только чтение - изменяем только через +/-
|
||||
qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных
|
||||
@@ -960,7 +1232,8 @@ function renderCart() {
|
||||
minusBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
const currentQty = cart.get(cartKey).qty;
|
||||
await updateCartItemQty(cartKey, currentQty - 1);
|
||||
const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию
|
||||
await updateCartItemQty(cartKey, roundQuantity(currentQty - step, 3));
|
||||
};
|
||||
|
||||
// Поле ввода количества
|
||||
@@ -970,12 +1243,18 @@ function renderCart() {
|
||||
qtyInput.style.width = '60px';
|
||||
qtyInput.style.textAlign = 'center';
|
||||
qtyInput.style.padding = '0.375rem 0.25rem';
|
||||
qtyInput.value = item.qty;
|
||||
qtyInput.value = roundQuantity(item.qty, 3);
|
||||
qtyInput.min = 1;
|
||||
qtyInput.step = item.quantity_step || 0.001; // Устанавливаем шаг единицы продажи
|
||||
qtyInput.onchange = async (e) => {
|
||||
const newQty = parseInt(e.target.value) || 1;
|
||||
const newQty = parseFloat(e.target.value) || 1;
|
||||
await updateCartItemQty(cartKey, newQty);
|
||||
};
|
||||
// Округление при потере фокуса
|
||||
qtyInput.onblur = (e) => {
|
||||
const rawValue = parseFloat(e.target.value) || 1;
|
||||
e.target.value = roundQuantity(rawValue, 3);
|
||||
};
|
||||
|
||||
// Кнопка плюс
|
||||
const plusBtn = document.createElement('button');
|
||||
@@ -984,7 +1263,8 @@ function renderCart() {
|
||||
plusBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
const currentQty = cart.get(cartKey).qty;
|
||||
await updateCartItemQty(cartKey, currentQty + 1);
|
||||
const step = item.quantity_step || 1; // Используем шаг единицы или 1 по умолчанию
|
||||
await updateCartItemQty(cartKey, roundQuantity(currentQty + step, 3));
|
||||
};
|
||||
|
||||
// Собираем контейнер
|
||||
@@ -2103,6 +2383,10 @@ async function handleCheckoutSubmit(paymentsData) {
|
||||
if (item.type === 'showcase_kit' && item.showcase_item_ids) {
|
||||
itemData.showcase_item_ids = item.showcase_item_ids;
|
||||
}
|
||||
// Для товаров с единицами продажи
|
||||
if (item.sales_unit_id) {
|
||||
itemData.sales_unit_id = item.sales_unit_id;
|
||||
}
|
||||
return itemData;
|
||||
}),
|
||||
payments: paymentsData,
|
||||
@@ -2222,6 +2506,51 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
renderCart(); // Отрисовываем восстановленную корзину
|
||||
}
|
||||
|
||||
// ===== ОБРАБОТЧИКИ ДЛЯ МОДАЛКИ ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
|
||||
|
||||
// Кнопки изменения количества
|
||||
document.getElementById('unitQtyDecrement').addEventListener('click', () => {
|
||||
const input = document.getElementById('unitModalQuantity');
|
||||
const step = parseFloat(input.step) || 1;
|
||||
const newValue = Math.max(parseFloat(input.min), parseFloat(input.value) - step);
|
||||
input.value = roundQuantity(newValue, 3);
|
||||
calculateUnitModalSubtotal();
|
||||
validateUnitQuantity();
|
||||
});
|
||||
|
||||
document.getElementById('unitQtyIncrement').addEventListener('click', () => {
|
||||
const input = document.getElementById('unitModalQuantity');
|
||||
const step = parseFloat(input.step) || 1;
|
||||
const newValue = parseFloat(input.value) + step;
|
||||
input.value = roundQuantity(newValue, 3);
|
||||
calculateUnitModalSubtotal();
|
||||
validateUnitQuantity();
|
||||
});
|
||||
|
||||
// Изменение количества вручную
|
||||
document.getElementById('unitModalQuantity').addEventListener('input', () => {
|
||||
calculateUnitModalSubtotal();
|
||||
validateUnitQuantity();
|
||||
});
|
||||
|
||||
// Округление количества при потере фокуса
|
||||
document.getElementById('unitModalQuantity').addEventListener('blur', (e) => {
|
||||
const rawValue = parseFloat(e.target.value) || 0;
|
||||
e.target.value = roundQuantity(rawValue, 3);
|
||||
calculateUnitModalSubtotal();
|
||||
validateUnitQuantity();
|
||||
});
|
||||
|
||||
// Изменение цены
|
||||
document.getElementById('unitModalPrice').addEventListener('input', () => {
|
||||
calculateUnitModalSubtotal();
|
||||
});
|
||||
|
||||
// Кнопка подтверждения добавления в корзину
|
||||
document.getElementById('confirmAddUnitToCart').addEventListener('click', () => {
|
||||
addToCartFromModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Смена склада
|
||||
|
||||
@@ -482,6 +482,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалка: Выбор единицы продажи товара -->
|
||||
<div class="modal fade" id="selectProductUnitModal" tabindex="-1" aria-labelledby="selectProductUnitModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="selectProductUnitModalLabel">
|
||||
<i class="bi bi-box-seam"></i> <span id="unitModalProductName"></span>
|
||||
</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="col-md-6">
|
||||
<label class="form-label fw-semibold">Выберите единицу продажи</label>
|
||||
<div id="unitSelectionList" class="d-flex flex-column gap-2" style="max-height: 400px; overflow-y: auto;">
|
||||
<!-- Заполняется через JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка: параметры -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<strong>Параметры добавления</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Выбранная единица -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Выбрана единица</label>
|
||||
<div id="selectedUnitDisplay" class="fw-semibold text-primary">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Количество -->
|
||||
<div class="mb-3">
|
||||
<label for="unitModalQuantity" class="form-label">Количество</label>
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" type="button" id="unitQtyDecrement">
|
||||
<i class="bi bi-dash"></i>
|
||||
</button>
|
||||
<input type="number" class="form-control text-center" id="unitModalQuantity"
|
||||
value="1" min="0.001" step="1">
|
||||
<button class="btn btn-outline-secondary" type="button" id="unitQtyIncrement">
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="unitQtyError" class="text-danger small mt-1" style="display: none;"></div>
|
||||
<div id="unitQtyHint" class="text-muted small mt-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Цена -->
|
||||
<div class="mb-3">
|
||||
<label for="unitModalPrice" class="form-label">Цена за единицу</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">₽</span>
|
||||
<input type="number" class="form-control" id="unitModalPrice"
|
||||
value="0" min="0" step="0.01">
|
||||
</div>
|
||||
<div id="priceOverrideIndicator" class="text-warning small mt-1" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle"></i> Цена изменена
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Итого -->
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Итого:</strong>
|
||||
<span class="fs-4" id="unitModalSubtotal">0.00 ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-success" id="confirmAddUnitToCart" disabled>
|
||||
<i class="bi bi-cart-plus"></i> Добавить в корзину
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -792,6 +792,10 @@ def get_items_api(request):
|
||||
reserved = p.reserved_qty
|
||||
free_qty = available - reserved
|
||||
|
||||
# Подсчитываем активные единицы продажи
|
||||
sales_units_count = p.sales_units.filter(is_active=True).count()
|
||||
has_sales_units = sales_units_count > 0
|
||||
|
||||
products.append({
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
@@ -804,7 +808,9 @@ def get_items_api(request):
|
||||
'available_qty': str(available),
|
||||
'reserved_qty': str(reserved),
|
||||
'free_qty': str(free_qty), # Передаём как строку для сохранения точности
|
||||
'free_qty_sort': float(free_qty) # Для сортировки отдельное поле
|
||||
'free_qty_sort': float(free_qty), # Для сортировки отдельное поле
|
||||
'sales_units_count': sales_units_count,
|
||||
'has_sales_units': has_sales_units
|
||||
})
|
||||
|
||||
# Prefetch для первого фото комплектов
|
||||
@@ -1434,21 +1440,28 @@ def pos_checkout(request):
|
||||
with db_transaction.atomic():
|
||||
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
|
||||
from django.utils import timezone as tz
|
||||
from orders.models import Delivery
|
||||
now_utc = tz.now() # Текущее время в UTC
|
||||
now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk)
|
||||
current_time = now_local.time() # Извлекаем время в минском часовом поясе
|
||||
|
||||
order = Order.objects.create(
|
||||
customer=customer,
|
||||
is_delivery=False, # POS - всегда самовывоз
|
||||
pickup_warehouse=warehouse,
|
||||
status=completed_status, # Сразу "Выполнен"
|
||||
delivery_date=now_local.date(), # Текущая дата в минском часовом поясе
|
||||
delivery_time_start=current_time, # Текущее время (Минск)
|
||||
delivery_time_end=current_time, # То же время (точное время)
|
||||
special_instructions=order_notes,
|
||||
modified_by=request.user
|
||||
)
|
||||
|
||||
# Создаём связанную доставку (самовывоз для POS)
|
||||
Delivery.objects.create(
|
||||
order=order,
|
||||
delivery_type=Delivery.DELIVERY_TYPE_PICKUP, # POS - всегда самовывоз
|
||||
pickup_warehouse=warehouse,
|
||||
delivery_date=now_local.date(), # Текущая дата в минском часовом поясе
|
||||
time_from=current_time, # Текущее время (Минск)
|
||||
time_to=current_time, # То же время (точное время)
|
||||
cost=0 # Самовывоз бесплатный
|
||||
)
|
||||
|
||||
# 2. Добавляем товары
|
||||
from inventory.models import ShowcaseItem
|
||||
|
||||
@@ -5,9 +5,10 @@ from django.http import JsonResponse
|
||||
from django.db import models
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.decorators import login_required
|
||||
import logging
|
||||
|
||||
from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto
|
||||
from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto, ProductSalesUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1360,7 +1361,7 @@ def get_payment_methods(request):
|
||||
'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
def get_product_sales_units_api(request, product_id):
|
||||
"""
|
||||
API для получения единиц продажи товара с остатками.
|
||||
|
||||
Reference in New Issue
Block a user