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:
2025-11-23 00:31:38 +03:00
parent 856e1ca4c1
commit d3d3c23695
5 changed files with 76 additions and 29 deletions

View File

@@ -7,16 +7,30 @@
{% block inventory_content %} {% block inventory_content %}
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Активные резервирования</h4> <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> <span class="badge bg-info">Только для просмотра</span>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
{% if not filtered_product %}
<div class="alert alert-info mb-3"> <div class="alert alert-info mb-3">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
<strong>Информация:</strong> Резервы создаются автоматически через POS (витринные комплекты) и заказы. <strong>Информация:</strong> Резервы создаются автоматически через POS (витринные комплекты) и заказы.
Прямое управление резервами отключено для предотвращения несогласованности данных. Прямое управление резервами отключено для предотвращения несогласованности данных.
</div> </div>
{% endif %}
{% if reservations %} {% if reservations %}
<div class="table-responsive"> <div class="table-responsive">
@@ -36,7 +50,9 @@
{% for r in reservations %} {% for r in reservations %}
<tr> <tr>
<td> <td>
<a href="{% url 'products:product-detail' r.product.pk %}" class="text-decoration-none" target="_blank">
<strong>{{ r.product.name }}</strong> <strong>{{ r.product.name }}</strong>
</a>
</td> </td>
<td> <td>
<span class="badge bg-secondary">{{ r.quantity|smart_quantity }}</span> <span class="badge bg-secondary">{{ r.quantity|smart_quantity }}</span>
@@ -78,10 +94,10 @@
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <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>
<li class="page-item"> <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> </li>
{% endif %} {% endif %}
@@ -93,10 +109,10 @@
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <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>
<li class="page-item"> <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> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -6,6 +6,7 @@ Reservation (Резервирование товара) views - READ ONLY
from django.views.generic import ListView from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from ..models import Reservation from ..models import Reservation
from products.models import Product
class ReservationListView(LoginRequiredMixin, ListView): class ReservationListView(LoginRequiredMixin, ListView):
@@ -19,7 +20,25 @@ class ReservationListView(LoginRequiredMixin, ListView):
paginate_by = 20 paginate_by = 20
def get_queryset(self): def get_queryset(self):
"""Показываем все резервы со статусом 'reserved'""" """Показываем все резервы со статусом 'reserved', с опциональной фильтрацией по товару"""
return Reservation.objects.filter( queryset = Reservation.objects.filter(
status='reserved' 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

View File

@@ -139,6 +139,16 @@
<th>Артикул:</th> <th>Артикул:</th>
<td>{{ product.sku }}</td> <td>{{ product.sku }}</td>
</tr> </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> <tr>
<th>Описание:</th> <th>Описание:</th>
<td>{{ product.description|default:"-" }}</td> <td>{{ product.description|default:"-" }}</td>
@@ -266,16 +276,6 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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> <tr>
<th>Статус:</th> <th>Статус:</th>
<td> <td>

View File

@@ -131,7 +131,7 @@
<th>Категория</th> <th>Категория</th>
<th style="width: 150px;">Теги</th> <th style="width: 150px;">Теги</th>
<th style="width: 130px;">Цена</th> <th style="width: 130px;">Цена</th>
<th style="width: 100px;">В наличии</th> <th style="width: 120px;">Остаток</th>
<th style="width: 100px;">Статус</th> <th style="width: 100px;">Статус</th>
<th style="width: 150px;">Действия</th> <th style="width: 150px;">Действия</th>
</tr> </tr>
@@ -193,10 +193,8 @@
</td> </td>
<td> <td>
{% if item.item_type == 'product' %} {% if item.item_type == 'product' %}
{% if item.in_stock %} <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>
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span> <small class="text-warning d-block">{{ item.total_reserved|floatformat:0 }} в резерве</small>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">-</span> <span class="text-muted">-</span>

View File

@@ -5,7 +5,8 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy 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 itertools import chain
from ..models import Product, ProductCategory, ProductTag, ProductKit from ..models import Product, ProductCategory, ProductTag, ProductKit
@@ -162,8 +163,14 @@ class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView)
permission_required = 'products.view_product' permission_required = 'products.view_product'
def get_queryset(self): 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@@ -253,7 +260,14 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
type_filter = self.request.GET.get('type', 'all') 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') kits = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos', 'tags')
# Применяем фильтры # Применяем фильтры