Compare commits
9 Commits
5a66d492c8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a7c0728f0 | |||
| 67ad0e50ee | |||
| 32bc0d2c39 | |||
| f140469a56 | |||
| d947f4eee7 | |||
| 5700314b10 | |||
| b24a0d9f21 | |||
| 034be20a5a | |||
| f75e861bb8 |
@@ -162,8 +162,6 @@ class ShowcaseManager:
|
||||
Raises:
|
||||
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
|
||||
"""
|
||||
from inventory.services.sale_processor import SaleProcessor
|
||||
|
||||
sold_count = 0
|
||||
order = order_item.order
|
||||
|
||||
@@ -207,17 +205,9 @@ class ShowcaseManager:
|
||||
|
||||
# Сначала устанавливаем order_item для правильного определения цены
|
||||
reservation.order_item = order_item
|
||||
reservation.save()
|
||||
|
||||
# Теперь создаём продажу с правильной ценой из OrderItem
|
||||
SaleProcessor.create_sale_from_reservation(
|
||||
reservation=reservation,
|
||||
order=order
|
||||
)
|
||||
|
||||
# Обновляем статус резерва
|
||||
reservation.status = 'converted_to_sale'
|
||||
reservation.converted_at = timezone.now()
|
||||
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
|
||||
# Это сделает сигнал create_sale_on_order_completion автоматически.
|
||||
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
|
||||
reservation.save()
|
||||
|
||||
sold_count += 1
|
||||
|
||||
@@ -366,7 +366,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
|
||||
# Проверяем, есть ли уже Sale для этого заказа
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
logger.info(f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем")
|
||||
logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем")
|
||||
update_is_returned_flag(instance)
|
||||
return
|
||||
|
||||
@@ -376,7 +376,7 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
previous_status = getattr(instance, '_previous_status', None)
|
||||
if previous_status and previous_status.is_positive_end:
|
||||
logger.info(
|
||||
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||
f"Заказ {instance.order_number}: повторный переход в положительный статус "
|
||||
f"({previous_status.name} → {instance.status.name}). Проверяем Sale..."
|
||||
)
|
||||
if Sale.objects.filter(order=instance).exists():
|
||||
@@ -454,13 +454,66 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
)
|
||||
continue
|
||||
|
||||
# === РАСЧЕТ ЦЕНЫ ===
|
||||
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
|
||||
# 1. Базовая стоимость позиции
|
||||
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
|
||||
|
||||
# 2. Скидки
|
||||
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
|
||||
|
||||
# Скидка на заказ (распределенная)
|
||||
instance.refresh_from_db()
|
||||
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
|
||||
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
|
||||
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
|
||||
|
||||
if order_total > 0 and order_discount_amount > 0:
|
||||
item_order_discount = order_discount_amount * (item_subtotal / order_total)
|
||||
else:
|
||||
item_order_discount = Decimal('0')
|
||||
|
||||
kit_net_total = item_subtotal - item_discount - item_order_discount
|
||||
if kit_net_total < 0:
|
||||
kit_net_total = Decimal('0')
|
||||
|
||||
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
|
||||
total_catalog_price = Decimal('0')
|
||||
for reservation in kit_reservations:
|
||||
qty = reservation.quantity_base or reservation.quantity
|
||||
price = reservation.product.actual_price or Decimal('0')
|
||||
total_catalog_price += price * qty
|
||||
|
||||
# 4. Коэффициент распределения
|
||||
if total_catalog_price > 0:
|
||||
ratio = kit_net_total / total_catalog_price
|
||||
else:
|
||||
# Если каталожная цена 0, распределяем просто по количеству или 0
|
||||
ratio = Decimal('0')
|
||||
|
||||
# Создаем Sale для каждого компонента комплекта
|
||||
for reservation in kit_reservations:
|
||||
try:
|
||||
# Рассчитываем цену продажи компонента пропорционально цене комплекта
|
||||
# Используем actual_price компонента как цену продажи
|
||||
component_sale_price = reservation.product.actual_price
|
||||
# Рассчитываем цену продажи компонента пропорционально
|
||||
catalog_price = reservation.product.actual_price or Decimal('0')
|
||||
|
||||
if ratio > 0:
|
||||
# Распределяем реальную выручку
|
||||
component_sale_price = catalog_price * ratio
|
||||
else:
|
||||
# Если выручка 0 или каталожные цены 0
|
||||
if total_catalog_price == 0 and kit_net_total > 0:
|
||||
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
|
||||
# Распределяем равномерно
|
||||
count = kit_reservations.count()
|
||||
component_qty = reservation.quantity_base or reservation.quantity
|
||||
if count > 0 and component_qty > 0:
|
||||
component_sale_price = (kit_net_total / count) / component_qty
|
||||
else:
|
||||
component_sale_price = Decimal('0')
|
||||
else:
|
||||
component_sale_price = Decimal('0')
|
||||
|
||||
sale = SaleProcessor.create_sale(
|
||||
product=reservation.product,
|
||||
warehouse=warehouse,
|
||||
@@ -472,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
sales_created.append(sale)
|
||||
logger.info(
|
||||
f"✓ Sale создан для компонента комплекта '{kit.name}': "
|
||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)"
|
||||
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
|
||||
f"(цена: {component_sale_price})"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
@@ -548,6 +602,21 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
||||
else:
|
||||
base_price = price_with_discount
|
||||
|
||||
# LOGGING DEBUG INFO
|
||||
# print(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
|
||||
# print(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
|
||||
# print(f" Item Discount: {item_discount}, Order Discount Share: {item_order_discount}, Total Discount: {total_discount}")
|
||||
# print(f" Price w/ Discount: {price_with_discount}")
|
||||
# print(f" Sales Unit: {item.sales_unit}, Conversion: {item.conversion_factor_snapshot}")
|
||||
# print(f" FINAL BASE PRICE: {base_price}")
|
||||
# print(f" Sales Unit Object: {item.sales_unit}")
|
||||
# if item.sales_unit:
|
||||
# print(f" Sales Unit Conversion: {item.sales_unit.conversion_factor}")
|
||||
|
||||
logger.info(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
|
||||
logger.info(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
|
||||
logger.info(f" FINAL BASE PRICE: {base_price}")
|
||||
|
||||
# Создаем Sale (с автоматическим FIFO-списанием)
|
||||
sale = SaleProcessor.create_sale(
|
||||
product=product,
|
||||
|
||||
@@ -394,14 +394,21 @@
|
||||
const emptyMessage = document.getElementById('empty-lines-message');
|
||||
if (emptyMessage) emptyMessage.remove();
|
||||
|
||||
// Добавляем новую строку
|
||||
// Добавляем новую строку в начало таблицы
|
||||
const newRow = self.createLineRow(data.line);
|
||||
tbody.appendChild(newRow);
|
||||
tbody.insertBefore(newRow, tbody.firstChild);
|
||||
|
||||
// Включаем кнопку завершения
|
||||
const completeBtn = document.getElementById('complete-inventory-btn');
|
||||
if (completeBtn) completeBtn.disabled = false;
|
||||
|
||||
|
||||
// Фокус на поле ввода количества в новой строке
|
||||
const quantityInput = newRow.querySelector('.quantity-fact-input');
|
||||
if (quantityInput) {
|
||||
quantityInput.focus();
|
||||
quantityInput.select();
|
||||
}
|
||||
|
||||
this.showNotification('Товар добавлен', 'success');
|
||||
} else {
|
||||
this.showNotification('Ошибка: ' + (data.error || 'Не удалось добавить товар'), 'error');
|
||||
|
||||
@@ -20,19 +20,22 @@
|
||||
<div class="row g-3">
|
||||
<!-- Основной контент - одна колонка -->
|
||||
<div class="col-12">
|
||||
<!-- Информация о документе -->
|
||||
<!-- Информация о документе - свернута по умолчанию -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||
{% if document.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||
{% elif document.status == 'confirmed' %}
|
||||
<span class="badge bg-success ms-2">Проведён</span>
|
||||
{% elif document.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
|
||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2" type="button" data-bs-toggle="collapse" data-bs-target="#document-info-collapse" aria-expanded="false" aria-controls="document-info-collapse">
|
||||
<i class="bi bi-chevron-down" id="document-info-collapse-icon"></i>
|
||||
<span>
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ document.document_number }}
|
||||
{% if document.status == 'draft' %}
|
||||
<span class="badge bg-warning text-dark ms-2">Черновик</span>
|
||||
{% elif document.status == 'confirmed' %}
|
||||
<span class="badge bg-success ms-2">Проведён</span>
|
||||
{% elif document.status == 'cancelled' %}
|
||||
<span class="badge bg-secondary ms-2">Отменён</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
{% if document.can_edit %}
|
||||
<div class="btn-group">
|
||||
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
|
||||
@@ -50,65 +53,67 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Склад</p>
|
||||
<p class="fw-semibold">{{ document.warehouse.name }}</p>
|
||||
<div class="collapse" id="document-info-collapse">
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Склад</p>
|
||||
<p class="fw-semibold">{{ document.warehouse.name }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Дата документа</p>
|
||||
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Тип поступления</p>
|
||||
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Создан</p>
|
||||
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Дата документа</p>
|
||||
<p class="fw-semibold">{{ document.date|date:"d.m.Y" }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Тип поступления</p>
|
||||
<p class="fw-semibold">{{ document.get_receipt_type_display }}</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<p class="text-muted small mb-1">Создан</p>
|
||||
<p class="fw-semibold">{{ document.created_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if document.supplier_name %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Поставщик</p>
|
||||
<p class="fw-semibold">{{ document.supplier_name }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.notes %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Примечания</p>
|
||||
<p>{{ document.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.confirmed_at %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Проведён</p>
|
||||
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
|
||||
{% if document.supplier_name %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Поставщик</p>
|
||||
<p class="fw-semibold">{{ document.supplier_name }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Провёл</p>
|
||||
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if document.notes %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted small mb-1">Примечания</p>
|
||||
<p>{{ document.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if document.confirmed_at %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Проведён</p>
|
||||
<p class="fw-semibold">{{ document.confirmed_at|date:"d.m.Y H:i" }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-muted small mb-1">Провёл</p>
|
||||
<p class="fw-semibold">{% if document.confirmed_by %}{{ document.confirmed_by.name|default:document.confirmed_by.email }}{% else %}-{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавление позиции -->
|
||||
{% if document.can_edit %}
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-light py-3">
|
||||
<div class="card-header bg-light py-1">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить позицию в документ</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Компонент поиска товаров -->
|
||||
<div class="mb-3">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
|
||||
<div class="card-body p-2">
|
||||
<!-- Компонент поиска товаров - компактный -->
|
||||
<div class="mb-2">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар...' warehouse_id=document.warehouse.id filter_in_stock_only=False skip_stock_filter=True categories=categories tags=tags add_button_text='Выбрать' content_height='150px' %}
|
||||
</div>
|
||||
|
||||
<!-- Информация о выбранном товаре -->
|
||||
@@ -217,11 +222,19 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
<span class="item-cost-price-display">{{ item.cost_price|floatformat:2 }}</span>
|
||||
{% if document.can_edit %}
|
||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
<span class="editable-cost-price"
|
||||
data-item-id="{{ item.id }}"
|
||||
data-current-value="{{ item.cost_price }}"
|
||||
title="Закупочная цена (клик для редактирования)"
|
||||
style="cursor: pointer;">
|
||||
{{ item.cost_price|floatformat:2 }}
|
||||
</span>
|
||||
<input type="number" class="form-control form-control-sm item-cost-price-input"
|
||||
value="{{ item.cost_price|stringformat:'g' }}" step="0.01" min="0"
|
||||
style="display: none; width: 100px; text-align: right; margin-left: auto;">
|
||||
{% else %}
|
||||
<span>{{ item.cost_price|floatformat:2 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-end" style="width: 120px;">
|
||||
@@ -271,24 +284,13 @@
|
||||
</td>
|
||||
{% if document.can_edit %}
|
||||
<td class="px-3 py-2 text-end" style="width: 100px;">
|
||||
<div class="btn-group btn-group-sm item-action-buttons">
|
||||
<button type="button" class="btn btn-outline-primary btn-edit-item" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="if(confirm('Удалить позицию?')) document.getElementById('delete-form-{{ item.id }}').submit();"
|
||||
title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm item-edit-buttons" style="display: none;">
|
||||
<button type="button" class="btn btn-success btn-save-item" title="Сохранить">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-cancel-edit" title="Отменить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="delete-form-{{ item.id }}" method="post"
|
||||
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
|
||||
style="display: none;">
|
||||
@@ -351,6 +353,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Анимация для иконки сворачивания/разворачивания информации о документе
|
||||
const documentInfoCollapse = document.getElementById('document-info-collapse');
|
||||
const documentInfoCollapseIcon = document.getElementById('document-info-collapse-icon');
|
||||
|
||||
if (documentInfoCollapse && documentInfoCollapseIcon) {
|
||||
documentInfoCollapse.addEventListener('show.bs.collapse', function() {
|
||||
documentInfoCollapseIcon.classList.remove('bi-chevron-down');
|
||||
documentInfoCollapseIcon.classList.add('bi-chevron-up');
|
||||
});
|
||||
|
||||
documentInfoCollapse.addEventListener('hide.bs.collapse', function() {
|
||||
documentInfoCollapseIcon.classList.remove('bi-chevron-up');
|
||||
documentInfoCollapseIcon.classList.add('bi-chevron-down');
|
||||
});
|
||||
}
|
||||
|
||||
// Функция выбора товара
|
||||
function selectProduct(product) {
|
||||
const productId = String(product.id).replace('product_', '');
|
||||
@@ -416,160 +434,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Inline редактирование позиций в таблице
|
||||
// ============================================
|
||||
|
||||
// Хранилище оригинальных значений при редактировании
|
||||
const originalValues = {};
|
||||
|
||||
// Обработчики для кнопок редактирования
|
||||
document.querySelectorAll('.btn-edit-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
// Сохраняем оригинальные значения
|
||||
originalValues[itemId] = {
|
||||
quantity: row.querySelector('.item-quantity-input').value,
|
||||
cost_price: row.querySelector('.item-cost-price-input').value,
|
||||
notes: row.querySelector('.item-notes-input').value
|
||||
};
|
||||
|
||||
// Переключаем в режим редактирования
|
||||
toggleEditMode(row, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для кнопок сохранения
|
||||
document.querySelectorAll('.btn-save-item').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
saveItemChanges(itemId, row);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для кнопок отмены
|
||||
document.querySelectorAll('.btn-cancel-edit').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const itemId = row.dataset.itemId;
|
||||
|
||||
// Восстанавливаем оригинальные значения
|
||||
if (originalValues[itemId]) {
|
||||
row.querySelector('.item-quantity-input').value = originalValues[itemId].quantity;
|
||||
row.querySelector('.item-cost-price-input').value = originalValues[itemId].cost_price;
|
||||
row.querySelector('.item-notes-input').value = originalValues[itemId].notes;
|
||||
}
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Переключение режима редактирования строки
|
||||
*/
|
||||
function toggleEditMode(row, isEditing) {
|
||||
// Переключаем видимость полей отображения/ввода
|
||||
row.querySelectorAll('.item-quantity-display, .item-cost-price-display, .item-notes-display').forEach(el => {
|
||||
el.style.display = isEditing ? 'none' : '';
|
||||
});
|
||||
row.querySelectorAll('.item-quantity-input, .item-cost-price-input, .item-notes-input').forEach(el => {
|
||||
el.style.display = isEditing ? '' : 'none';
|
||||
});
|
||||
|
||||
// Переключаем видимость кнопок
|
||||
row.querySelector('.item-action-buttons').style.display = isEditing ? 'none' : '';
|
||||
row.querySelector('.item-edit-buttons').style.display = isEditing ? '' : 'none';
|
||||
|
||||
// Фокус на поле количества при входе в режим редактирования
|
||||
if (isEditing) {
|
||||
const qtyInput = row.querySelector('.item-quantity-input');
|
||||
if (qtyInput) {
|
||||
qtyInput.focus();
|
||||
qtyInput.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменений позиции
|
||||
*/
|
||||
function saveItemChanges(itemId, row) {
|
||||
const quantity = row.querySelector('.item-quantity-input').value;
|
||||
const costPrice = row.querySelector('.item-cost-price-input').value;
|
||||
const notes = row.querySelector('.item-notes-input').value;
|
||||
|
||||
// Валидация
|
||||
if (!quantity || parseFloat(quantity) <= 0) {
|
||||
alert('Количество должно быть больше нуля');
|
||||
return;
|
||||
}
|
||||
if (!costPrice || parseFloat(costPrice) < 0) {
|
||||
alert('Закупочная цена не может быть отрицательной');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем на сервер
|
||||
const formData = new FormData();
|
||||
formData.append('quantity', quantity);
|
||||
formData.append('cost_price', costPrice);
|
||||
formData.append('notes', notes);
|
||||
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
||||
|
||||
// Блокируем кнопки во время сохранения
|
||||
const saveBtn = row.querySelector('.btn-save-item');
|
||||
const cancelBtn = row.querySelector('.btn-cancel-edit');
|
||||
saveBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||||
|
||||
fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Обновляем отображение
|
||||
let formattedQty = parseFloat(quantity);
|
||||
if (formattedQty === Math.floor(formattedQty)) {
|
||||
formattedQty = Math.floor(formattedQty).toString();
|
||||
} else {
|
||||
formattedQty = formattedQty.toString().replace('.', ',');
|
||||
}
|
||||
row.querySelector('.item-quantity-display').textContent = formattedQty;
|
||||
row.querySelector('.item-cost-price-display').textContent = parseFloat(costPrice).toFixed(2);
|
||||
row.querySelector('.item-notes-display').textContent = notes || '-';
|
||||
|
||||
// Пересчитываем сумму
|
||||
const totalCost = (parseFloat(quantity) * parseFloat(costPrice)).toFixed(2);
|
||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||
|
||||
// Выходим из режима редактирования
|
||||
toggleEditMode(row, false);
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при сохранении');
|
||||
})
|
||||
.finally(() => {
|
||||
saveBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Inline редактирование количества
|
||||
// Inline редактирование количества и цены
|
||||
// ============================================
|
||||
|
||||
function initInlineQuantityEdit() {
|
||||
@@ -708,14 +576,146 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
function initInlineCostPriceEdit() {
|
||||
// Проверяем, есть ли на странице редактируемые цены
|
||||
const editableCostPrices = document.querySelectorAll('.editable-cost-price');
|
||||
if (editableCostPrices.length === 0) {
|
||||
return; // Нет элементов для редактирования
|
||||
}
|
||||
|
||||
// Обработчик клика на редактируемую цену
|
||||
document.addEventListener('click', function(e) {
|
||||
const costPriceSpan = e.target.closest('.editable-cost-price');
|
||||
if (!costPriceSpan) return;
|
||||
|
||||
// Предотвращаем повторное срабатывание, если уже редактируем
|
||||
if (costPriceSpan.querySelector('input')) return;
|
||||
|
||||
const itemId = costPriceSpan.dataset.itemId;
|
||||
const currentValue = costPriceSpan.dataset.currentValue;
|
||||
|
||||
// Сохраняем оригинальный HTML
|
||||
const originalHTML = costPriceSpan.innerHTML;
|
||||
|
||||
// Создаем input для редактирования
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.style.width = '100px';
|
||||
input.style.textAlign = 'right';
|
||||
input.value = parseFloat(currentValue).toFixed(2);
|
||||
input.step = '0.01';
|
||||
input.min = '0';
|
||||
input.placeholder = 'Цена';
|
||||
|
||||
// Заменяем содержимое на input
|
||||
costPriceSpan.innerHTML = '';
|
||||
costPriceSpan.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
// Функция сохранения
|
||||
const saveCostPrice = async () => {
|
||||
let newValue = input.value.trim();
|
||||
|
||||
// Валидация
|
||||
if (!newValue || parseFloat(newValue) < 0) {
|
||||
alert('Закупочная цена не может быть отрицательной');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, изменилось ли значение
|
||||
if (parseFloat(newValue) === parseFloat(currentValue)) {
|
||||
// Значение не изменилось
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем загрузку
|
||||
input.disabled = true;
|
||||
input.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
// Получаем текущие значения других полей
|
||||
const row = costPriceSpan.closest('tr');
|
||||
const quantity = row.querySelector('.item-quantity-input').value;
|
||||
const notes = row.querySelector('.item-notes-input').value;
|
||||
|
||||
const response = await fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
quantity: quantity,
|
||||
cost_price: newValue,
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Обновляем отображение
|
||||
const formattedPrice = parseFloat(newValue).toFixed(2);
|
||||
costPriceSpan.textContent = formattedPrice;
|
||||
costPriceSpan.dataset.currentValue = newValue;
|
||||
|
||||
// Пересчитываем сумму
|
||||
const totalCost = (parseFloat(quantity) * parseFloat(newValue)).toFixed(2);
|
||||
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
|
||||
|
||||
// Обновляем итого
|
||||
updateTotals();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при обновлении цены');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка сети при обновлении цены');
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция отмены
|
||||
const cancelEdit = () => {
|
||||
costPriceSpan.innerHTML = originalHTML;
|
||||
};
|
||||
|
||||
// Enter - сохранить
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveCostPrice();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
|
||||
// Потеря фокуса - сохранить
|
||||
input.addEventListener('blur', function() {
|
||||
setTimeout(saveCostPrice, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Функция обновления итоговых сумм
|
||||
function updateTotals() {
|
||||
// Можно реализовать пересчет итогов, если нужно
|
||||
// Пока оставим как есть, так как сервер возвращает обновленные данные
|
||||
}
|
||||
|
||||
// Инициализация inline редактирования количества
|
||||
// Инициализация inline редактирования
|
||||
initInlineQuantityEdit();
|
||||
initInlineCostPriceEdit();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -745,6 +745,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
color: #0d6efd !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Стили для редактируемой цены */
|
||||
.editable-cost-price {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.editable-cost-price:hover {
|
||||
color: #0d6efd !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -19,52 +19,76 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Информация об инвентаризации - свернута по умолчанию -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h5>Информация</h5>
|
||||
<table class="table table-borderless">
|
||||
{% if inventory.document_number %}
|
||||
<tr>
|
||||
<th>Номер документа:</th>
|
||||
<td><strong>{{ inventory.document_number }}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Склад:</th>
|
||||
<td><strong>{{ inventory.warehouse.name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
{% if inventory.status == 'draft' %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<i class="bi bi-file-earmark"></i> Черновик
|
||||
</span>
|
||||
{% elif inventory.status == 'processing' %}
|
||||
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
|
||||
<i class="bi bi-hourglass-split"></i> В обработке
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">
|
||||
<i class="bi bi-check-circle-fill"></i> Завершена
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата:</th>
|
||||
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% if inventory.conducted_by %}
|
||||
<tr>
|
||||
<th>Провёл:</th>
|
||||
<td>{{ inventory.conducted_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
<button class="btn btn-outline-primary btn-sm d-flex align-items-center gap-2 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#inventory-info-collapse" aria-expanded="false" aria-controls="inventory-info-collapse">
|
||||
<i class="bi bi-chevron-down" id="info-collapse-icon"></i>
|
||||
<span>Информация</span>
|
||||
</button>
|
||||
<div class="collapse" id="inventory-info-collapse">
|
||||
<table class="table table-borderless">
|
||||
{% if inventory.document_number %}
|
||||
<tr>
|
||||
<th>Номер документа:</th>
|
||||
<td><strong>{{ inventory.document_number }}</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>Склад:</th>
|
||||
<td><strong>{{ inventory.warehouse.name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
{% if inventory.status == 'draft' %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||
<i class="bi bi-file-earmark"></i> Черновик
|
||||
</span>
|
||||
{% elif inventory.status == 'processing' %}
|
||||
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
|
||||
<i class="bi bi-hourglass-split"></i> В обработке
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">
|
||||
<i class="bi bi-check-circle-fill"></i> Завершена
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата:</th>
|
||||
<td>{{ inventory.date|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% if inventory.conducted_by %}
|
||||
<tr>
|
||||
<th>Провёл:</th>
|
||||
<td>{{ inventory.conducted_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем простую анимацию для иконки при сворачивании/разворачивании
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const collapseElement = document.getElementById('inventory-info-collapse');
|
||||
const collapseIcon = document.getElementById('info-collapse-icon');
|
||||
|
||||
collapseElement.addEventListener('show.bs.collapse', function() {
|
||||
collapseIcon.classList.remove('bi-chevron-down');
|
||||
collapseIcon.classList.add('bi-chevron-up');
|
||||
});
|
||||
|
||||
collapseElement.addEventListener('hide.bs.collapse', function() {
|
||||
collapseIcon.classList.remove('bi-chevron-up');
|
||||
collapseIcon.classList.add('bi-chevron-down');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if inventory.status == 'completed' %}
|
||||
<!-- Информация о созданных документах -->
|
||||
<div class="alert alert-info mb-4">
|
||||
@@ -100,14 +124,14 @@
|
||||
|
||||
<h5>Строки инвентаризации</h5>
|
||||
|
||||
<!-- Компонент поиска товаров (только если не завершена) -->
|
||||
<!-- Компонент поиска товаров - открыт по умолчанию, компактный -->
|
||||
{% if inventory.status != 'completed' %}
|
||||
<div class="card border-primary mb-4" id="product-search-section">
|
||||
<div class="card-header bg-light">
|
||||
<div class="card-header bg-light py-1">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' skip_stock_filter=True %}
|
||||
<div class="card-body p-2">
|
||||
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить' content_height='150px' skip_stock_filter=True %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -74,10 +74,12 @@
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
|
||||
<a href="{% url 'products:product-detail' item.product.id %}">{{
|
||||
item.product.name }}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
|
||||
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="badge bg-secondary">{{ item.batch.id }}</span>
|
||||
</td>
|
||||
@@ -132,9 +134,11 @@
|
||||
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash me-1"></i>Удалить
|
||||
</a>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,13 +147,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.breadcrumb-sm {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -39,9 +39,11 @@
|
||||
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<!--
|
||||
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
|
||||
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
|
||||
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,11 +16,18 @@ from inventory.models import Reservation
|
||||
import json
|
||||
|
||||
|
||||
from django.utils import timezone # Added for default date filter
|
||||
|
||||
def order_list(request):
|
||||
"""
|
||||
Список всех заказов с фильтрацией и поиском
|
||||
Использует django-filter для фильтрации данных
|
||||
"""
|
||||
# Если параметров нет вообще (первый заход), редиректим на "Сегодня"
|
||||
if not request.GET:
|
||||
today = timezone.localdate().isoformat()
|
||||
return redirect(f'{request.path}?delivery_date_after={today}&delivery_date_before={today}')
|
||||
|
||||
# Базовый queryset с оптимизацией запросов
|
||||
orders = Order.objects.select_related(
|
||||
'customer', 'delivery', 'delivery__address', 'delivery__pickup_warehouse', 'status' # Добавлен 'status' для избежания N+1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -729,6 +729,28 @@
|
||||
|
||||
<!-- Модалка редактирования товара в корзине -->
|
||||
{% include 'pos/components/edit_cart_item_modal.html' %}
|
||||
|
||||
<!-- Toast Container для уведомлений -->
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1060;">
|
||||
<div id="orderSuccessToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-check-circle-fill text-success me-2 fs-5"></i>
|
||||
<span id="toastMessage"></span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" style="display: none;"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="orderErrorToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-exclamation-circle-fill text-danger me-2 fs-5"></i>
|
||||
<span id="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -207,6 +207,18 @@
|
||||
self._toggleProduct(productId);
|
||||
}
|
||||
});
|
||||
|
||||
// Двойной клик по товару - сразу добавляет в документ
|
||||
this.elements.grid.addEventListener('dblclick', function(e) {
|
||||
var productCard = e.target.closest('.product-picker-item');
|
||||
if (productCard && self.options.onAddSelected) {
|
||||
var productId = productCard.dataset.productId;
|
||||
var product = self._findProductById(productId);
|
||||
if (product) {
|
||||
self.options.onAddSelected(product, self);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавить выбранный
|
||||
@@ -435,17 +447,7 @@
|
||||
*/
|
||||
ProductSearchPicker.prototype._toggleProduct = function(productId) {
|
||||
var self = this;
|
||||
var product = null;
|
||||
|
||||
// Находим товар в списке
|
||||
for (var i = 0; i < this.state.products.length; i++) {
|
||||
var p = this.state.products[i];
|
||||
if (String(p.id).replace('product_', '') === productId) {
|
||||
product = p;
|
||||
product.id = productId; // Сохраняем очищенный ID
|
||||
break;
|
||||
}
|
||||
}
|
||||
var product = this._findProductById(productId);
|
||||
|
||||
if (!product) return;
|
||||
|
||||
@@ -473,6 +475,21 @@
|
||||
this._updateSelectionUI();
|
||||
};
|
||||
|
||||
/**
|
||||
* Поиск товара по ID в загруженном списке
|
||||
*/
|
||||
ProductSearchPicker.prototype._findProductById = function(productId) {
|
||||
for (var i = 0; i < this.state.products.length; i++) {
|
||||
var p = this.state.products[i];
|
||||
if (String(p.id).replace('product_', '') === productId) {
|
||||
var product = Object.assign({}, p);
|
||||
product.id = productId; // Сохраняем очищенный ID
|
||||
return product;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Принудительно снять выделение со всех товаров
|
||||
*/
|
||||
|
||||
@@ -48,27 +48,27 @@ ProductSearchPicker.init('#writeoff-products', {
|
||||
{% if skip_stock_filter %}data-skip-stock-filter="true"{% endif %}>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<!-- Строка поиска -->
|
||||
<div class="card-header bg-white py-3">
|
||||
<!-- Строка поиска - компактный размер -->
|
||||
<div class="card-header bg-white py-1">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="bi bi-search text-primary"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control form-control-lg border-start-0 product-picker-search"
|
||||
class="form-control form-control-sm border-start-0 product-picker-search"
|
||||
placeholder="{{ title|default:'Поиск товара по названию, артикулу...' }}"
|
||||
style="box-shadow: none;">
|
||||
<button class="btn btn-outline-secondary product-picker-search-clear"
|
||||
<button class="btn btn-outline-secondary btn-sm product-picker-search-clear"
|
||||
type="button" style="display: none;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_filters|default:True %}
|
||||
<!-- Фильтры -->
|
||||
<div class="card-body border-bottom py-2">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<!-- Фильтры - компактный вид -->
|
||||
<div class="card-body border-bottom py-1">
|
||||
<div class="d-flex gap-1 align-items-center flex-wrap">
|
||||
{% if categories %}
|
||||
<!-- Фильтр по категории -->
|
||||
<select class="form-select form-select-sm product-picker-category" style="width: auto;">
|
||||
@@ -113,29 +113,29 @@ ProductSearchPicker.init('#writeoff-products', {
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Контент: сетка/список товаров -->
|
||||
<div class="card-body product-picker-content" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
||||
<!-- Контент: сетка/список товаров - компактный -->
|
||||
<div class="card-body product-picker-content p-1" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
|
||||
<!-- Индикатор загрузки -->
|
||||
<div class="product-picker-loading text-center py-4" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<div class="product-picker-loading text-center py-2" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка товаров -->
|
||||
<div class="row g-2 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
||||
<div class="row g-1 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
|
||||
<!-- Товары загружаются через AJAX -->
|
||||
</div>
|
||||
|
||||
<!-- Пустой результат -->
|
||||
<div class="product-picker-empty text-center py-4 text-muted" style="display: none;">
|
||||
<i class="bi bi-search fs-1 opacity-25"></i>
|
||||
<p class="mb-0 mt-2">Товары не найдены</p>
|
||||
<div class="product-picker-empty text-center py-2 text-muted" style="display: none;">
|
||||
<i class="bi bi-search fs-5 opacity-25"></i>
|
||||
<p class="mb-0 mt-1 small">Товары не найдены</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Футер с кнопкой действия -->
|
||||
<div class="card-footer bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<!-- Футер с кнопкой действия - компактный -->
|
||||
<div class="card-footer bg-white py-1 d-flex justify-content-between align-items-center flex-wrap gap-1">
|
||||
<div></div>
|
||||
|
||||
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
|
||||
|
||||
120
myproject/reproduce_issue.py
Normal file
120
myproject/reproduce_issue.py
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import django
|
||||
from decimal import Decimal
|
||||
|
||||
# Setup Django
|
||||
sys.path.append(os.getcwd())
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
||||
django.setup()
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
|
||||
from customers.models import Customer
|
||||
from inventory.models import Warehouse, Sale
|
||||
from products.models import Product, UnitOfMeasure
|
||||
from pos.views import pos_checkout
|
||||
from orders.models import OrderStatus
|
||||
|
||||
def run():
|
||||
# Setup Data
|
||||
User = get_user_model()
|
||||
user = User.objects.first()
|
||||
if not user:
|
||||
print("No user found")
|
||||
return
|
||||
|
||||
# Create/Get Customer
|
||||
customer, _ = Customer.objects.get_or_create(
|
||||
name="Test Customer",
|
||||
defaults={'phone': '+375291112233'}
|
||||
)
|
||||
|
||||
# Create/Get Warehouse
|
||||
warehouse, _ = Warehouse.objects.get_or_create(
|
||||
name="Test Warehouse",
|
||||
defaults={'is_active': True}
|
||||
)
|
||||
|
||||
# Create product
|
||||
product, _ = Product.objects.get_or_create(
|
||||
name="Test Product Debug",
|
||||
defaults={
|
||||
'sku': 'DEBUG001',
|
||||
'buying_price': 10,
|
||||
'actual_price': 50,
|
||||
'warehouse': warehouse
|
||||
}
|
||||
)
|
||||
product.actual_price = 50
|
||||
product.save()
|
||||
|
||||
# Ensure OrderStatus exists
|
||||
OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True})
|
||||
OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'})
|
||||
|
||||
# Prepare Request
|
||||
factory = RequestFactory()
|
||||
|
||||
payload = {
|
||||
"customer_id": customer.id,
|
||||
"warehouse_id": warehouse.id,
|
||||
"items": [
|
||||
{
|
||||
"type": "product",
|
||||
"id": product.id,
|
||||
"quantity": 1,
|
||||
"price": 100.00, # Custom price
|
||||
"quantity_base": 1
|
||||
}
|
||||
],
|
||||
"payments": [
|
||||
{"payment_method": "cash", "amount": 100.00}
|
||||
],
|
||||
"notes": "Debug Sale"
|
||||
}
|
||||
|
||||
request = factory.post(
|
||||
'/pos/api/checkout/',
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json'
|
||||
)
|
||||
request.user = user
|
||||
|
||||
print("Executing pos_checkout...")
|
||||
response = pos_checkout(request)
|
||||
print(f"Response: {response.content}")
|
||||
|
||||
# Verify Sale
|
||||
sales = Sale.objects.filter(product=product).order_by('-id')[:1]
|
||||
if sales:
|
||||
sale = sales[0]
|
||||
print(f"Sale created. ID: {sale.id}")
|
||||
print(f"Sale Quantity: {sale.quantity}")
|
||||
print(f"Sale Price: {sale.sale_price}")
|
||||
if sale.sale_price == 0:
|
||||
print("FAILURE: Sale price is 0!")
|
||||
else:
|
||||
print(f"SUCCESS: Sale price is {sale.sale_price}")
|
||||
else:
|
||||
print("FAILURE: No Sale created!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
from django_tenants.utils import schema_context
|
||||
# Replace with actual schema name if needed, assuming 'public' for now or the default tenant
|
||||
# Since I don't know the tenant, I'll try to run in the current context.
|
||||
# But usually need to set schema.
|
||||
# Let's try to find a tenant.
|
||||
from tenants.models import Client
|
||||
tenant = Client.objects.first()
|
||||
if tenant:
|
||||
print(f"Running in tenant: {tenant.schema_name}")
|
||||
with schema_context(tenant.schema_name):
|
||||
run()
|
||||
else:
|
||||
print("No tenant found, running in public?")
|
||||
run()
|
||||
Reference in New Issue
Block a user