Add stock availability display to product list and detail views
- Add total_available, total_reserved, total_free annotations to product queries - Display free stock (green/red) with reserved count in product list - Show detailed stock info in product detail page (moved to top) - Make reservation count clickable to view filtered reservations - Add product filter support to ReservationListView - Add product link in reservation list for easy navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,16 +7,30 @@
|
||||
{% block inventory_content %}
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">Активные резервирования</h4>
|
||||
<span class="badge bg-info">Только для просмотра</span>
|
||||
<h4 class="mb-0">
|
||||
Активные резервирования
|
||||
{% if filtered_product %}
|
||||
<small class="text-muted">— {{ filtered_product.name }}</small>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<div>
|
||||
{% if filtered_product %}
|
||||
<a href="{% url 'inventory:reservation-list' %}" class="btn btn-sm btn-outline-secondary me-2">
|
||||
<i class="bi bi-x-circle"></i> Сбросить фильтр
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="badge bg-info">Только для просмотра</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{% if not filtered_product %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Информация:</strong> Резервы создаются автоматически через POS (витринные комплекты) и заказы.
|
||||
Прямое управление резервами отключено для предотвращения несогласованности данных.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if reservations %}
|
||||
<div class="table-responsive">
|
||||
@@ -36,7 +50,9 @@
|
||||
{% for r in reservations %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ r.product.name }}</strong>
|
||||
<a href="{% url 'products:product-detail' r.product.pk %}" class="text-decoration-none" target="_blank">
|
||||
<strong>{{ r.product.name }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ r.quantity|smart_quantity }}</span>
|
||||
@@ -78,10 +94,10 @@
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1">Первая</a>
|
||||
<a class="page-link" href="?page=1{% if filtered_product %}&product={{ filtered_product.pk }}{% endif %}">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if filtered_product %}&product={{ filtered_product.pk }}{% endif %}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -93,10 +109,10 @@
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if filtered_product %}&product={{ filtered_product.pk }}{% endif %}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if filtered_product %}&product={{ filtered_product.pk }}{% endif %}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,7 @@ Reservation (Резервирование товара) views - READ ONLY
|
||||
from django.views.generic import ListView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from ..models import Reservation
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class ReservationListView(LoginRequiredMixin, ListView):
|
||||
@@ -19,7 +20,25 @@ class ReservationListView(LoginRequiredMixin, ListView):
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
"""Показываем все резервы со статусом 'reserved'"""
|
||||
return Reservation.objects.filter(
|
||||
"""Показываем все резервы со статусом 'reserved', с опциональной фильтрацией по товару"""
|
||||
queryset = Reservation.objects.filter(
|
||||
status='reserved'
|
||||
).select_related('product', 'warehouse', 'order_item', 'showcase').order_by('-reserved_at')
|
||||
).select_related('product', 'warehouse', 'order_item', 'showcase', 'product_kit').order_by('-reserved_at')
|
||||
|
||||
# Фильтрация по товару
|
||||
product_id = self.request.GET.get('product')
|
||||
if product_id:
|
||||
queryset = queryset.filter(product_id=product_id)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Если фильтруем по товару, добавляем его в контекст
|
||||
product_id = self.request.GET.get('product')
|
||||
if product_id:
|
||||
try:
|
||||
context['filtered_product'] = Product.objects.get(pk=product_id)
|
||||
except Product.DoesNotExist:
|
||||
pass
|
||||
return context
|
||||
|
||||
@@ -139,6 +139,16 @@
|
||||
<th>Артикул:</th>
|
||||
<td>{{ product.sku }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Остаток:</th>
|
||||
<td>
|
||||
<strong class="fs-5 {% if product.total_free > 0 %}text-success{% else %}text-danger{% endif %}">{{ product.total_free|floatformat:0 }}</strong> <span class="text-muted">свободно</span>
|
||||
{% if product.total_reserved > 0 %}
|
||||
<a href="{% url 'inventory:reservation-list' %}?product={{ product.pk }}" class="ms-2 text-warning text-decoration-none" target="_blank" title="Посмотреть резервы"><i class="bi bi-lock"></i> {{ product.total_reserved|floatformat:0 }} в резерве <i class="bi bi-box-arrow-up-right small"></i></a>
|
||||
{% endif %}
|
||||
<span class="ms-2 text-muted small">(всего: {{ product.total_available|floatformat:0 }})</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Описание:</th>
|
||||
<td>{{ product.description|default:"-" }}</td>
|
||||
@@ -266,16 +276,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>В наличии:</th>
|
||||
<td>
|
||||
{% if product.in_stock %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да, в наличии</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет, закончился</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<th>Категория</th>
|
||||
<th style="width: 150px;">Теги</th>
|
||||
<th style="width: 130px;">Цена</th>
|
||||
<th style="width: 100px;">В наличии</th>
|
||||
<th style="width: 120px;">Остаток</th>
|
||||
<th style="width: 100px;">Статус</th>
|
||||
<th style="width: 150px;">Действия</th>
|
||||
</tr>
|
||||
@@ -193,10 +193,8 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if item.item_type == 'product' %}
|
||||
{% if item.in_stock %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||
<strong class="{% if item.total_free > 0 %}text-success{% else %}text-danger{% endif %}">{{ item.total_free|floatformat:0 }}</strong>{% if item.total_reserved > 0 %}<small class="text-muted"> / {{ item.total_available|floatformat:0 }}</small>
|
||||
<small class="text-warning d-block">{{ item.total_reserved|floatformat:0 }} в резерве</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
|
||||
@@ -5,7 +5,8 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Sum, Value, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
from itertools import chain
|
||||
|
||||
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
||||
@@ -162,8 +163,14 @@ class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView)
|
||||
permission_required = 'products.view_product'
|
||||
|
||||
def get_queryset(self):
|
||||
# Предзагрузка фотографий для избежания N+1 запросов
|
||||
return super().get_queryset().prefetch_related('photos')
|
||||
# Предзагрузка фотографий и аннотация остатков
|
||||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||||
return super().get_queryset().prefetch_related('photos').annotate(
|
||||
total_available=total_available,
|
||||
total_reserved=total_reserved,
|
||||
total_free=total_available - total_reserved,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@@ -253,7 +260,14 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
|
||||
type_filter = self.request.GET.get('type', 'all')
|
||||
|
||||
# Получаем товары и комплекты (только постоянные комплекты)
|
||||
products = Product.objects.prefetch_related('categories', 'photos', 'tags')
|
||||
# Аннотируем товары данными об остатках из агрегированной таблицы Stock
|
||||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||||
products = Product.objects.prefetch_related('categories', 'photos', 'tags').annotate(
|
||||
total_available=total_available,
|
||||
total_reserved=total_reserved,
|
||||
total_free=total_available - total_reserved,
|
||||
)
|
||||
kits = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos', 'tags')
|
||||
|
||||
# Применяем фильтры
|
||||
|
||||
Reference in New Issue
Block a user