Исправлена ошибка создания заказов в 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

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

View File

@@ -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();
});
});
// Смена склада

View File

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

View File

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