Исправлена ошибка создания заказов в 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:
2026-01-02 17:46:32 +03:00
parent 1ead77b2d8
commit 275bc1b78d
7 changed files with 528 additions and 227 deletions

View File

@@ -274,12 +274,25 @@ class ShowcaseManager:
# Создаём заказ # Создаём заказ
order = Order.objects.create( order = Order.objects.create(
customer=customer, customer=customer,
is_delivery=False,
pickup_warehouse=warehouse,
status=completed_status, status=completed_status,
is_paid=True, is_paid=True,
modified_by=user 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 price = custom_price if custom_price else product_kit.actual_price

View File

@@ -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 сигналы!'))

View File

@@ -356,3 +356,70 @@ body {
font-size: 0.8rem; 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;
}

View File

@@ -34,6 +34,12 @@ let editingKitId = null;
// Временная корзина для модального окна создания/редактирования комплекта // Временная корзина для модального окна создания/редактирования комплекта
const tempCart = new Map(); const tempCart = new Map();
// ===== ПЕРЕМЕННЫЕ ДЛЯ МОДАЛЬНОГО ОКНА ВЫБОРА ЕДИНИЦЫ ПРОДАЖИ =====
let unitModalProduct = null; // Текущий товар для модального окна
let unitModalSalesUnits = []; // Список единиц продажи
let selectedSalesUnit = null; // Выбранная единица продажи
let unitModalInstance = null; // Bootstrap Modal instance
// ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS ===== // ===== СОХРАНЕНИЕ КОРЗИНЫ В REDIS =====
let saveCartTimeout = null; 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() { function renderCategories() {
const grid = document.getElementById('categoryGrid'); const grid = document.getElementById('categoryGrid');
grid.innerHTML = ''; grid.innerHTML = '';
@@ -755,6 +1010,13 @@ function setupInfiniteScroll() {
} }
async function addToCart(item) { 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" const cartKey = `${item.type}-${item.id}`; // Уникальный ключ: "product-1" или "kit-1"
// СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock) // СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ ВИТРИННЫХ КОМПЛЕКТОВ (Soft Lock)
@@ -819,7 +1081,8 @@ async function addToCart(item) {
if (!cart.has(cartKey)) { if (!cart.has(cartKey)) {
cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type }); cart.set(cartKey, { id: item.id, name: item.name, price: Number(item.price), qty: 1, type: item.type });
} else { } 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); const item = cart.get(cartKey);
if (!item) return; if (!item) return;
if (newQty <= 0) { // Округляем новое количество
const roundedQty = roundQuantity(newQty, 3);
if (roundedQty <= 0) {
await removeFromCart(cartKey); await removeFromCart(cartKey);
} else { } else {
item.qty = newQty; item.qty = roundedQty;
renderCart(); renderCart();
saveCartToRedis(); saveCartToRedis();
@@ -898,10 +1164,16 @@ function renderCart() {
if (item.type === 'kit' || item.type === 'showcase_kit') { if (item.type === 'kit' || item.type === 'showcase_kit') {
typeIcon = '<i class="bi bi-box-seam text-info" title="Комплект"></i> '; 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 = ` namePrice.innerHTML = `
<div class="fw-semibold small">${typeIcon}${item.name}</div> <div class="fw-semibold small">${typeIcon}${item.name}${unitInfo}</div>
<div class="text-muted" style="font-size: 0.75rem;">${formatMoney(item.price)} / шт</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.width = '60px';
qtyInput.style.textAlign = 'center'; qtyInput.style.textAlign = 'center';
qtyInput.style.padding = '0.375rem 0.25rem'; qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty; qtyInput.value = roundQuantity(item.qty, 3);
qtyInput.min = 1; qtyInput.min = 1;
qtyInput.readOnly = true; // Только чтение - изменяем только через +/- qtyInput.readOnly = true; // Только чтение - изменяем только через +/-
qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных qtyInput.style.backgroundColor = '#fff3cd'; // Желтый фон как у витринных
@@ -960,7 +1232,8 @@ function renderCart() {
minusBtn.onclick = async (e) => { minusBtn.onclick = async (e) => {
e.preventDefault(); e.preventDefault();
const currentQty = cart.get(cartKey).qty; 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.width = '60px';
qtyInput.style.textAlign = 'center'; qtyInput.style.textAlign = 'center';
qtyInput.style.padding = '0.375rem 0.25rem'; qtyInput.style.padding = '0.375rem 0.25rem';
qtyInput.value = item.qty; qtyInput.value = roundQuantity(item.qty, 3);
qtyInput.min = 1; qtyInput.min = 1;
qtyInput.step = item.quantity_step || 0.001; // Устанавливаем шаг единицы продажи
qtyInput.onchange = async (e) => { qtyInput.onchange = async (e) => {
const newQty = parseInt(e.target.value) || 1; const newQty = parseFloat(e.target.value) || 1;
await updateCartItemQty(cartKey, newQty); 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'); const plusBtn = document.createElement('button');
@@ -984,7 +1263,8 @@ function renderCart() {
plusBtn.onclick = async (e) => { plusBtn.onclick = async (e) => {
e.preventDefault(); e.preventDefault();
const currentQty = cart.get(cartKey).qty; 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) { if (item.type === 'showcase_kit' && item.showcase_item_ids) {
itemData.showcase_item_ids = 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; return itemData;
}), }),
payments: paymentsData, payments: paymentsData,
@@ -2222,6 +2506,51 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
renderCart(); // Отрисовываем восстановленную корзину 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();
});
}); });
// Смена склада // Смена склада

View File

@@ -482,6 +482,91 @@
</div> </div>
</div> </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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View File

@@ -792,6 +792,10 @@ def get_items_api(request):
reserved = p.reserved_qty reserved = p.reserved_qty
free_qty = available - reserved free_qty = available - reserved
# Подсчитываем активные единицы продажи
sales_units_count = p.sales_units.filter(is_active=True).count()
has_sales_units = sales_units_count > 0
products.append({ products.append({
'id': p.id, 'id': p.id,
'name': p.name, 'name': p.name,
@@ -804,7 +808,9 @@ def get_items_api(request):
'available_qty': str(available), 'available_qty': str(available),
'reserved_qty': str(reserved), 'reserved_qty': str(reserved),
'free_qty': str(free_qty), # Передаём как строку для сохранения точности '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 для первого фото комплектов # Prefetch для первого фото комплектов
@@ -1434,21 +1440,28 @@ def pos_checkout(request):
with db_transaction.atomic(): with db_transaction.atomic():
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk) # 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
from django.utils import timezone as tz from django.utils import timezone as tz
from orders.models import Delivery
now_utc = tz.now() # Текущее время в UTC now_utc = tz.now() # Текущее время в UTC
now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk) now_local = tz.localtime(now_utc) # Конвертируем в локальный часовой пояс (Europe/Minsk)
current_time = now_local.time() # Извлекаем время в минском часовом поясе current_time = now_local.time() # Извлекаем время в минском часовом поясе
order = Order.objects.create( order = Order.objects.create(
customer=customer, customer=customer,
is_delivery=False, # POS - всегда самовывоз
pickup_warehouse=warehouse,
status=completed_status, # Сразу "Выполнен" status=completed_status, # Сразу "Выполнен"
delivery_date=now_local.date(), # Текущая дата в минском часовом поясе
delivery_time_start=current_time, # Текущее время (Минск)
delivery_time_end=current_time, # То же время (точное время)
special_instructions=order_notes, special_instructions=order_notes,
modified_by=request.user 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. Добавляем товары # 2. Добавляем товары
from inventory.models import ShowcaseItem from inventory.models import ShowcaseItem

View File

@@ -5,9 +5,10 @@ from django.http import JsonResponse
from django.db import models from django.db import models
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth.decorators import login_required
import logging import logging
from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory, ProductPhoto, ProductSalesUnit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1360,7 +1361,7 @@ def get_payment_methods(request):
'error': f'Ошибка при загрузке способов оплаты: {str(e)}' 'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
}, status=500) }, status=500)
@login_required
def get_product_sales_units_api(request, product_id): def get_product_sales_units_api(request, product_id):
""" """
API для получения единиц продажи товара с остатками. API для получения единиц продажи товара с остатками.