fix: Улучшения системы ценообразования комплектов

Исправлены 4 проблемы:
1. Расчёт цены первого товара - улучшена валидация в getProductPrice и calculateFinalPrice
2. Отображение actual_price в Select2 вместо обычной цены
3. Количество по умолчанию = 1 для новых форм компонентов
4. Auto-select текста при клике на поле количества для удобства редактирования

Изменённые файлы:
- products/forms.py: добавлен __init__ в KitItemForm для quantity.initial = 1
- products/templates/includes/select2-product-init.html: обновлена formatSelectResult
- products/templates/productkit_create.html: добавлен focus handler для auto-select
- products/templates/productkit_edit.html: добавлен focus handler для auto-select

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 19:04:03 +03:00
parent c84a372f98
commit 6c8af5ab2c
120 changed files with 9035 additions and 3036 deletions

View File

@@ -14,12 +14,12 @@ from inventory.models import (
# ===== WAREHOUSE =====
@admin.register(Warehouse)
class WarehouseAdmin(admin.ModelAdmin):
list_display = ('name', 'is_active', 'created_at')
list_filter = ('is_active', 'created_at')
list_display = ('name', 'is_default_display', 'is_active', 'created_at')
list_filter = ('is_active', 'is_default', 'created_at')
search_fields = ('name',)
fieldsets = (
('Основная информация', {
'fields': ('name', 'description', 'is_active')
'fields': ('name', 'description', 'is_active', 'is_default')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
@@ -28,6 +28,12 @@ class WarehouseAdmin(admin.ModelAdmin):
)
readonly_fields = ('created_at', 'updated_at')
def is_default_display(self, obj):
if obj.is_default:
return format_html('<span style="color: #ff9900; font-weight: bold;">★ По умолчанию</span>')
return '-'
is_default_display.short_description = 'По умолчанию'
# ===== STOCK BATCH =====
@admin.register(StockBatch)

View File

@@ -10,11 +10,12 @@ from products.models import Product
class WarehouseForm(forms.ModelForm):
class Meta:
model = Warehouse
fields = ['name', 'description', 'is_active']
fields = ['name', 'description', 'is_active', 'is_default']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_default': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
@@ -145,6 +146,19 @@ class InventoryForm(forms.ModelForm):
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные склады (исключаем скрытые)
self.fields['warehouse'].queryset = Warehouse.objects.filter(is_active=True)
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
if not self.initial.get('warehouse'):
default_warehouse = Warehouse.objects.filter(
is_active=True,
is_default=True
).first()
if default_warehouse:
self.initial['warehouse'] = default_warehouse.id
class InventoryLineForm(forms.ModelForm):
class Meta:
@@ -199,6 +213,17 @@ class IncomingHeaderForm(forms.Form):
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
if not self.initial.get('warehouse'):
default_warehouse = Warehouse.objects.filter(
is_active=True,
is_default=True
).first()
if default_warehouse:
self.initial['warehouse'] = default_warehouse.id
def clean_document_number(self):
document_number = self.cleaned_data.get('document_number', '')
if document_number:
@@ -292,6 +317,17 @@ class IncomingForm(forms.Form):
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Если есть склад по умолчанию и значение не установлено явно - предвыбираем его
if not self.initial.get('warehouse'):
default_warehouse = Warehouse.objects.filter(
is_active=True,
is_default=True
).first()
if default_warehouse:
self.initial['warehouse'] = default_warehouse.id
def clean_document_number(self):
document_number = self.cleaned_data.get('document_number', '')
if document_number:

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.4 on 2025-10-28 23:32
# Generated by Django 5.0.10 on 2025-10-30 21:24
import django.db.models.deletion
from django.db import migrations, models
@@ -101,13 +101,14 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('is_default', models.BooleanField(default=False, help_text='Автоматически выбирается при создании новых документов', verbose_name='Склад по умолчанию')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Склад',
'verbose_name_plural': 'Склады',
'indexes': [models.Index(fields=['is_active'], name='inventory_w_is_acti_3ddeac_idx')],
'indexes': [models.Index(fields=['is_active'], name='inventory_w_is_acti_3ddeac_idx'), models.Index(fields=['is_default'], name='inventory_w_is_defa_4b7615_idx')],
},
),
migrations.AddField(

View File

@@ -12,6 +12,11 @@ class Warehouse(models.Model):
name = models.CharField(max_length=200, verbose_name="Название")
description = models.TextField(blank=True, null=True, verbose_name="Описание")
is_active = models.BooleanField(default=True, verbose_name="Активен")
is_default = models.BooleanField(
default=False,
verbose_name="Склад по умолчанию",
help_text="Автоматически выбирается при создании новых документов"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
@@ -20,11 +25,19 @@ class Warehouse(models.Model):
verbose_name_plural = "Склады"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_default']),
]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Обеспечиваем что только один склад может быть по умолчанию в рамках одного тенанта"""
if self.is_default:
# Снимаем флаг is_default со всех других складов этого тенанта
Warehouse.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
class StockBatch(models.Model):
"""

View File

@@ -386,3 +386,126 @@ def update_product_in_stock_on_stock_delete(sender, instance, **kwargs):
"""
product_id = instance.product_id
_update_product_in_stock(product_id)
# ============================================================================
# Сигналы для автоматического обновления себестоимости товара (cost_price)
# ============================================================================
@receiver(post_save, sender=StockBatch)
def update_product_cost_on_batch_change(sender, instance, created, **kwargs):
"""
Сигнал: При создании или изменении партии (StockBatch) автоматически
обновляется себестоимость товара (Product.cost_price).
Процесс:
1. Проверяем, есть ли связанный товар
2. Вызываем ProductCostCalculator для пересчета средневзвешенной стоимости
3. Обновляем поле cost_price в БД
Триггеры:
- Создание новой партии (поступление товара)
- Изменение количества в партии
- Изменение стоимости партии
"""
if not instance.product:
return
# Импортируем здесь чтобы избежать circular import
from products.services.cost_calculator import ProductCostCalculator
try:
# Пересчитываем и обновляем себестоимость товара
ProductCostCalculator.update_product_cost(instance.product, save=True)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Ошибка при обновлении себестоимости товара {instance.product.sku} "
f"после изменения партии {instance.id}: {e}",
exc_info=True
)
@receiver(post_delete, sender=StockBatch)
def update_product_cost_on_batch_delete(sender, instance, **kwargs):
"""
Сигнал: При удалении партии (StockBatch) автоматически
обновляется себестоимость товара.
Процесс:
1. После удаления партии пересчитываем себестоимость
2. Если партий не осталось - cost_price становится 0.00
"""
if not instance.product:
return
# Импортируем здесь чтобы избежать circular import
from products.services.cost_calculator import ProductCostCalculator
try:
# Пересчитываем и обновляем себестоимость товара
ProductCostCalculator.update_product_cost(instance.product, save=True)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Ошибка при обновлении себестоимости товара после удаления партии: {e}",
exc_info=True
)
# ============================================================================
# Сигналы для динамического пересчета цен комплектов
# ============================================================================
@receiver(post_save, sender='products.Product')
def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
"""
Сигнал: При изменении цены товара (price или sale_price)
автоматически пересчитываются цены всех комплектов, где используется этот товар.
Процесс:
1. Находим все KitItem с этим товаром
2. Для каждого комплекта вызываем recalculate_base_price()
3. base_price и price обновляются в БД
Триггеры:
- Изменение price (основная цена товара)
- Изменение sale_price (цена со скидкой товара)
"""
from products.models import KitItem
# Если это создание товара (не обновление), нет комплектов для пересчета
if created:
return
# Находим все KitItem с этим товаром
kit_items = KitItem.objects.filter(product=instance)
if not kit_items.exists():
return # Товар не используется в комплектах
# Для каждого комплекта пересчитываем цены
kits_to_update = set()
for item in kit_items:
kits_to_update.add(item.kit_id)
# Обновляем цены каждого комплекта
from products.models import ProductKit
for kit_id in kits_to_update:
try:
kit = ProductKit.objects.get(id=kit_id)
kit.recalculate_base_price()
except ProductKit.DoesNotExist:
pass
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Ошибка при пересчете цены комплекта {kit_id} "
f"после изменения цены товара {instance.sku}: {e}",
exc_info=True
)

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Распределение продаж{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Распределение продаж по партиям (FIFO)</h4></div><div class="card-body">{% if allocations %}<table class="table table-hover table-sm"><thead><tr><th>Продажа</th><th>Товар</th><th>Партия</th><th>Кол-во</th><th>Цена</th><th>Дата</th></tr></thead><tbody>{% for a in allocations %}<tr><td>#{{ a.sale.id }}</td><td>{{ a.sale.product.name }}</td><td>#{{ a.batch.id }}</td><td>{{ a.quantity }}</td><td>{{ a.cost_price }}</td><td>{{ a.sale.date|date:"d.m.Y" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Распределений не найдено.</div>{% endif %}</div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Распределение продаж по партиям (FIFO)</h4></div><div class="card-body">{% if allocations %}<table class="table table-hover table-sm"><thead><tr><th>Продажа</th><th>Товар</th><th>Партия</th><th>Кол-во</th><th>Цена</th><th>Дата</th></tr></thead><tbody>{% for a in allocations %}<tr><td>#{{ a.sale.id }}</td><td>{{ a.sale.product.name }}</td><td>#{{ a.batch.id }}</td><td>{{ a.quantity|smart_quantity }}</td><td>{{ a.cost_price }}</td><td>{{ a.sale.date|date:"d.m.Y" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Распределений не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Партия товара{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Партия #{{ batch.id }}: {{ batch.product.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td>{{ batch.product.name }}</td></tr><tr><th>Склад:</th><td>{{ batch.warehouse.name }}</td></tr><tr><th>Количество:</th><td><strong>{{ batch.quantity }} шт</strong></td></tr><tr><th>Цена закупки:</th><td>{{ batch.cost_price }} </td></tr><tr><th>Создана:</th><td>{{ batch.created_at|date:"d.m.Y H:i" }}</td></tr><tr><th>Статус:</th><td>{% if batch.is_active %}<span class="badge bg-success">Активна</span>{% else %}<span class="badge bg-secondary">Неактивна</span>{% endif %}</td></tr></table><h5 class="mt-4">История операций</h5><div class="alert alert-info">История продаж и списаний этой партии.</div><a href="{% url 'inventory:batch-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Партия #{{ batch.id }}: {{ batch.product.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td>{{ batch.product.name }}</td></tr><tr><th>Склад:</th><td>{{ batch.warehouse.name }}</td></tr><tr><th>Количество:</th><td><strong>{{ batch.quantity|smart_quantity }} шт</strong></td></tr><tr><th>Цена закупки:</th><td>{{ batch.cost_price }} руб.</td></tr><tr><th>Создана:</th><td>{{ batch.created_at|date:"d.m.Y H:i" }}</td></tr><tr><th>Статус:</th><td>{% if batch.is_active %}<span class="badge bg-success">Активна</span>{% else %}<span class="badge bg-secondary">Неактивна</span>{% endif %}</td></tr></table><h5 class="mt-4">История операций</h5><div class="alert alert-info">История продаж и списаний этой партии.</div><a href="{% url 'inventory:batch-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Партии товаров{% endblock %}
{% block inventory_content %}
<div class="card">
@@ -28,8 +29,8 @@
</td>
<td>{{ batch.product.name }}</td>
<td>{{ batch.warehouse.name }}</td>
<td>{{ batch.quantity }}</td>
<td>{{ batch.cost_price }} </td>
<td>{{ batch.quantity|smart_quantity }}</td>
<td>{{ batch.cost_price }} руб.</td>
<td>{{ batch.created_at|date:"d.m.Y" }}</td>
<td>
<a href="{% url 'inventory:batch-detail' batch.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр партии">

View File

@@ -3,147 +3,310 @@
{% block title %}Склад{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row mb-4">
<div class="container-fluid py-4">
<div class="row mb-5">
<div class="col-12">
<h1 class="display-5">Управление складом</h1>
<p class="lead text-muted">Здесь будут инструменты для управления инвентаризацией и складским учетом</p>
<h1 class="mb-2">Управление складом</h1>
<p class="text-muted">Выберите операцию для работы</p>
</div>
</div>
<div class="row">
<!-- Основные операции -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-building"></i> Управление складами
</h5>
<p class="card-text text-muted">Создание и управление физическими складами</p>
<a href="{% url 'inventory:warehouse-list' %}" class="btn btn-outline-primary">Перейти</a>
<!-- Основные операции -->
<div class="row mb-5">
<div class="col-12">
<h5 class="text-uppercase text-muted mb-3">
<small>Основные операции</small>
</h5>
</div>
</div>
<div class="row g-3 mb-5">
<!-- Управление складами -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:warehouse-list' %}" class="card-link">
<div class="card h-100 compact-card primary-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper primary-icon mb-3">
<i class="bi bi-building"></i>
</div>
<h6 class="card-title">Управление складами</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-down-square"></i> Приход товара
</h5>
<p class="card-text text-muted">Регистрация поступления товаров на склад</p>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-outline-primary">Перейти</a>
<!-- Приход товара -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:incoming-list' %}" class="card-link">
<div class="card h-100 compact-card success-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper success-icon mb-3">
<i class="bi bi-arrow-down-square"></i>
</div>
<h6 class="card-title">Приход товара</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-up-square"></i> Реализация товара
</h5>
<p class="card-text text-muted">Учет проданных товаров с применением FIFO</p>
<a href="{% url 'inventory:sale-list' %}" class="btn btn-outline-primary">Перейти</a>
<!-- Реализация товара -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:sale-list' %}" class="card-link">
<div class="card h-100 compact-card warning-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper warning-icon mb-3">
<i class="bi bi-arrow-up-square"></i>
</div>
<h6 class="card-title">Реализация товара</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-clipboard-check"></i> Инвентаризация
</h5>
<p class="card-text text-muted">Проверка фактических остатков и корректировка</p>
<a href="{% url 'inventory:inventory-list' %}" class="btn btn-outline-primary">Перейти</a>
<!-- Инвентаризация -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:inventory-list' %}" class="card-link">
<div class="card h-100 compact-card info-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper info-icon mb-3">
<i class="bi bi-clipboard-check"></i>
</div>
<h6 class="card-title">Инвентаризация</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<!-- Дополнительные операции -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-x-circle"></i> Списание товара
</h5>
<p class="card-text text-muted">Списание брака, порчи, недостач</p>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-outline-secondary">Перейти</a>
<!-- Списание товара -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:writeoff-list' %}" class="card-link">
<div class="card h-100 compact-card danger-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper danger-icon mb-3">
<i class="bi bi-x-circle"></i>
</div>
<h6 class="card-title">Списание товара</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-arrow-left-right"></i> Перемещение товара
</h5>
<p class="card-text text-muted">Перемещение между складами с сохранением партийности</p>
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary">Перейти</a>
<!-- Перемещение товара -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:transfer-list' %}" class="card-link">
<div class="card h-100 compact-card secondary-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper secondary-icon mb-3">
<i class="bi bi-arrow-left-right"></i>
</div>
<h6 class="card-title">Перемещение товара</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
</div>
<!-- Справочная информация -->
<div class="row mb-5">
<div class="col-12">
<h5 class="text-uppercase text-muted mb-3">
<small>Справочная информация</small>
</h5>
</div>
</div>
<div class="row g-3">
<!-- Остатки товаров -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:stock-list' %}" class="card-link">
<div class="card h-100 compact-card stock-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper stock-icon mb-3">
<i class="bi bi-box-seam"></i>
</div>
<h6 class="card-title">Остатки товаров</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</a>
</div>
<!-- Справочная информация -->
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-box-seam"></i> Остатки товаров
</h5>
<p class="card-text text-muted">Просмотр текущих остатков по складам и товарам</p>
<a href="{% url 'inventory:stock-list' %}" class="btn btn-outline-info">Перейти</a>
<!-- Партии товаров -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:batch-list' %}" class="card-link">
<div class="card h-100 compact-card batch-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper batch-icon mb-3">
<i class="bi bi-diagram-3"></i>
</div>
<h6 class="card-title">Партии товаров</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</a>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-diagram-3"></i> Партии товаров
</h5>
<p class="card-text text-muted">История партий и их распределение</p>
<a href="{% url 'inventory:batch-list' %}" class="btn btn-outline-info">Перейти</a>
<!-- Журнал операций -->
<div class="col-lg-4 col-md-6">
<a href="{% url 'inventory:movement-list' %}" class="card-link">
<div class="card h-100 compact-card journal-card">
<div class="card-body d-flex flex-column">
<div class="icon-wrapper journal-icon mb-3">
<i class="bi bi-journal-check"></i>
</div>
<h6 class="card-title">Журнал операций</h6>
<div class="mt-auto">
<span class="btn-text">Перейти →</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-journal-check"></i> Журнал операций
</h5>
<p class="card-text text-muted">Полный журнал всех складских движений</p>
<a href="{% url 'inventory:movement-list' %}" class="btn btn-outline-info">Перейти</a>
</div>
</div>
</a>
</div>
</div>
</div>
<style>
.card {
.card-link {
text-decoration: none;
color: inherit;
}
.compact-card {
border: none;
border-radius: 8px;
transition: transform 0.2s, box-shadow 0.2s;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
min-height: 160px;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.card-title {
font-weight: 600;
margin-bottom: 1rem;
.compact-card:hover {
transform: translateY(-8px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
.card-body {
padding: 1.5rem;
padding: 1.25rem;
}
.card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.icon-wrapper {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 600;
}
/* Цветовые схемы для иконок */
.primary-icon {
background: rgba(13, 110, 253, 0.15);
color: #0d6efd;
}
.success-icon {
background: rgba(25, 135, 84, 0.15);
color: #198754;
}
.warning-icon {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
.info-icon {
background: rgba(23, 162, 184, 0.15);
color: #17a2b8;
}
.danger-icon {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
}
.secondary-icon {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
}
.stock-icon {
background: rgba(13, 202, 240, 0.15);
color: #0dcaf0;
}
.batch-icon {
background: rgba(111, 66, 193, 0.15);
color: #6f42c1;
}
.journal-icon {
background: rgba(253, 126, 20, 0.15);
color: #fd7e14;
}
.btn-text {
font-size: 0.85rem;
font-weight: 500;
color: #6c757d;
transition: all 0.2s ease;
}
.compact-card:hover .btn-text {
color: #0d6efd;
}
/* Легкий фон для вызва категорий */
.text-uppercase {
letter-spacing: 1px;
font-weight: 600;
font-size: 0.75rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.compact-card {
min-height: 140px;
}
.icon-wrapper {
width: 45px;
height: 45px;
font-size: 1.5rem;
}
.card-title {
font-size: 0.9rem;
}
}
</style>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Массовое поступление товара{% endblock %}
{% block inventory_content %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Отмена приходу товара{% endblock %}
@@ -23,8 +24,8 @@
<ul class="mb-0">
<li><strong>Товар:</strong> {{ incoming.product.name }}</li>
<li><strong>Склад:</strong> {{ incoming.warehouse.name }}</li>
<li><strong>Количество:</strong> {{ incoming.quantity }} шт</li>
<li><strong>Цена закупки:</strong> {{ incoming.cost_price }} </li>
<li><strong>Количество:</strong> {{ incoming.quantity|smart_quantity }} шт</li>
<li><strong>Цена закупки:</strong> {{ incoming.cost_price }} руб.</li>
{% if incoming.document_number %}
<li><strong>Номер документа:</strong> {{ incoming.document_number }}</li>
{% endif %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}
{% if form.instance.pk %}
@@ -55,7 +56,7 @@
<label for="{{ form.quantity.id_for_label }}" class="form-label">
{{ form.quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity }}
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">
{% for error in form.quantity.errors %}{{ error }}{% endfor %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}История приходов товара{% endblock %}
@@ -32,8 +33,8 @@
<tr>
<td><strong>{{ incoming.product.name }}</strong></td>
<td>{{ incoming.batch.warehouse.name }}</td>
<td>{{ incoming.quantity }} шт</td>
<td>{{ incoming.cost_price }} </td>
<td>{{ incoming.quantity|smart_quantity }} шт</td>
<td>{{ incoming.cost_price }} руб.</td>
<td>
{% if incoming.batch.document_number %}
<code>{{ incoming.batch.document_number }}</code>

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Партия {{ batch.document_number }}{% endblock %}
{% block inventory_content %}
<div class="card">
@@ -71,11 +72,11 @@
{% for item in items %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.cost_price }} </td>
<td>{{ item.quantity|smart_quantity }}</td>
<td>{{ item.cost_price }} руб.</td>
<td>
{% widthratio item.quantity 1 item.cost_price as total_price %}
<strong>{{ total_price|floatformat:2 }} </strong>
<strong>{{ total_price|floatformat:2 }} руб.</strong>
</td>
<td>
{% if item.stock_batch %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Детали инвентаризации{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Внесение результатов инвентаризации{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Новое резервирование{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Резервирование товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.product.label }} *</label>{{ form.product }}</div><div class="mb-3"><label class="form-label">{{ form.warehouse.label }} *</label>{{ form.warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity }}</div><div class="mb-3"><label class="form-label">{{ form.order_item.label }}</label>{{ form.order_item }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:reservation-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Резервирование товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.product.label }} *</label>{{ form.product }}</div><div class="mb-3"><label class="form-label">{{ form.warehouse.label }} *</label>{{ form.warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity|smart_quantity }}</div><div class="mb-3"><label class="form-label">{{ form.order_item.label }}</label>{{ form.order_item }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:reservation-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<style>select,input{width:100%;}</style>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Резервирования{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Активные резервирования <a href="{% url 'inventory:reservation-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if reservations %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Кол-во</th><th>Склад</th><th>Зарезервировано</th><th class="text-end">Действия</th></tr></thead><tbody>{% for r in reservations %}<tr><td>{{ r.product.name }}</td><td>{{ r.quantity }}</td><td>{{ r.warehouse.name }}</td><td>{{ r.reserved_at|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:reservation-update' r.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a></td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Резервирований не найдено.</div>{% endif %}</div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Активные резервирования <a href="{% url 'inventory:reservation-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if reservations %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Кол-во</th><th>Склад</th><th>Зарезервировано</th><th class="text-end">Действия</th></tr></thead><tbody>{% for r in reservations %}<tr><td>{{ r.product.name }}</td><td>{{ r.quantity|smart_quantity }}</td><td>{{ r.warehouse.name }}</td><td>{{ r.reserved_at|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:reservation-update' r.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a></td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Резервирований не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Отмена продажи{% endblock %}
@@ -23,8 +24,8 @@
<ul class="mb-0">
<li><strong>Товар:</strong> {{ sale.product.name }}</li>
<li><strong>Склад:</strong> {{ sale.warehouse.name }}</li>
<li><strong>Количество:</strong> {{ sale.quantity }} шт</li>
<li><strong>Цена продажи:</strong> {{ sale.sale_price }} </li>
<li><strong>Количество:</strong> {{ sale.quantity|smart_quantity }} шт</li>
<li><strong>Цена продажи:</strong> {{ sale.sale_price }} руб.</li>
<li><strong>Статус:</strong>
{% if sale.processed %}
Обработана

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Детали продажи{% endblock %}
@@ -26,15 +27,15 @@
</tr>
<tr>
<th>Количество:</th>
<td><strong>{{ sale.quantity }} шт</strong></td>
<td><strong>{{ sale.quantity|smart_quantity }} шт</strong></td>
</tr>
<tr>
<th>Цена продажи:</th>
<td><strong>{{ sale.sale_price }} </strong></td>
<td><strong>{{ sale.sale_price }} руб.</strong></td>
</tr>
<tr>
<th>Сумма:</th>
<td><strong>{{ sale.quantity|add:0|multiply:sale.sale_price }} </strong></td>
<td><strong>{{ sale.quantity|add:0|multiply:sale.sale_price }} руб.</strong></td>
</tr>
</table>
</div>
@@ -96,16 +97,16 @@
<code>Партия #{{ allocation.batch.id }}</code>
</td>
<td>{{ allocation.batch.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ allocation.quantity }} шт</td>
<td>{{ allocation.cost_price }} </td>
<td><strong>{{ allocation.quantity|add:0|multiply:allocation.cost_price }} </strong></td>
<td>{{ allocation.quantity|smart_quantity }} шт</td>
<td>{{ allocation.cost_price }} руб.</td>
<td><strong>{{ allocation.quantity|add:0|multiply:allocation.cost_price }} руб.</strong></td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="2">Итого:</th>
<th>{{ sale.quantity }} шт</th>
<th>{{ sale.quantity|smart_quantity }} шт</th>
<th colspan="2">
<strong>
{% comment %} Сумма всех закупочных цен {% endcomment %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}
{% if form.instance.pk %}
@@ -55,7 +56,7 @@
<label for="{{ form.quantity.id_for_label }}" class="form-label">
{{ form.quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity }}
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">
{% for error in form.quantity.errors %}{{ error }}{% endfor %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}История продаж{% endblock %}
@@ -32,8 +33,8 @@
<tr>
<td><strong>{{ sale.product.name }}</strong></td>
<td>{{ sale.warehouse.name }}</td>
<td>{{ sale.quantity }} шт</td>
<td>{{ sale.sale_price }} </td>
<td>{{ sale.quantity|smart_quantity }} шт</td>
<td>{{ sale.sale_price }} руб.</td>
<td>
{% if sale.order %}
<code>{{ sale.order.order_number }}</code>

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Остатки товара{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">{{ stock.product.name }} на {{ stock.warehouse.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td><strong>{{ stock.product.name }}</strong></td></tr><tr><th>Склад:</th><td>{{ stock.warehouse.name }}</td></tr><tr><th>Доступно:</th><td><strong>{{ stock.quantity_available }} шт</strong></td></tr><tr><th>Зарезервировано:</th><td>{{ stock.quantity_reserved }} шт</td></tr><tr><th>Свободно:</th><td><strong>{{ stock.quantity_free }} шт</strong></td></tr><tr><th>Последнее обновление:</th><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr></table><a href="{% url 'inventory:stock-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">{{ stock.product.name }} на {{ stock.warehouse.name }}</h4></div><div class="card-body"><table class="table table-borderless"><tr><th>Товар:</th><td><strong>{{ stock.product.name }}</strong></td></tr><tr><th>Склад:</th><td>{{ stock.warehouse.name }}</td></tr><tr><th>Доступно:</th><td><strong>{{ stock.quantity_available|smart_quantity }} шт</strong></td></tr><tr><th>Зарезервировано:</th><td>{{ stock.quantity_reserved|smart_quantity }} шт</td></tr><tr><th>Свободно:</th><td><strong>{{ stock.quantity_free|smart_quantity }} шт</strong></td></tr><tr><th>Последнее обновление:</th><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr></table><a href="{% url 'inventory:stock-list' %}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Вернуться</a></div></div>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Остатки товаров{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Остатки на складах</h4></div><div class="card-body">{% if stocks %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Склад</th><th>Доступно</th><th>Зарезервировано</th><th>Свободно</th><th>Последний обновления</th></tr></thead><tbody>{% for stock in stocks %}<tr><td><a href="{% url 'inventory:stock-detail' stock.pk %}">{{ stock.product.name }}</a></td><td>{{ stock.warehouse.name }}</td><td>{{ stock.quantity_available }}</td><td>{{ stock.quantity_reserved }}</td><td><strong>{{ stock.quantity_free }}</strong></td><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Остатки не найдены.</div>{% endif %}</div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Остатки на складах</h4></div><div class="card-body">{% if stocks %}<table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Склад</th><th>Доступно</th><th>Зарезервировано</th><th>Свободно</th><th>Последний обновления</th></tr></thead><tbody>{% for stock in stocks %}<tr><td><a href="{% url 'inventory:stock-detail' stock.pk %}">{{ stock.product.name }}</a></td><td>{{ stock.warehouse.name }}</td><td>{{ stock.quantity_available|smart_quantity }}</td><td>{{ stock.quantity_reserved|smart_quantity }}</td><td><strong>{{ stock.quantity_free|smart_quantity }}</strong></td><td>{{ stock.updated_at|date:"d.m.Y H:i" }}</td></tr>{% endfor %}</tbody></table>{% else %}<div class="alert alert-info">Остатки не найдены.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Перемещение товара{% endblock %}
{% block inventory_content %}
<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.batch.label }} *</label>{{ form.batch }}</div><div class="mb-3"><label class="form-label">{{ form.from_warehouse.label }} *</label>{{ form.from_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.to_warehouse.label }} *</label>{{ form.to_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity }}</div><div class="mb-3"><label class="form-label">{{ form.document_number.label }}</label>{{ form.document_number }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:transfer-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товара</h4></div><div class="card-body"><form method="post">{% csrf_token %}<div class="mb-3"><label class="form-label">{{ form.batch.label }} *</label>{{ form.batch }}</div><div class="mb-3"><label class="form-label">{{ form.from_warehouse.label }} *</label>{{ form.from_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.to_warehouse.label }} *</label>{{ form.to_warehouse }}</div><div class="mb-3"><label class="form-label">{{ form.quantity.label }} *</label>{{ form.quantity|smart_quantity }}</div><div class="mb-3"><label class="form-label">{{ form.document_number.label }}</label>{{ form.document_number }}</div><div class="d-flex gap-2"><button type="submit" class="btn btn-primary">Сохранить</button><a href="{% url 'inventory:transfer-list' %}" class="btn btn-secondary">Отмена</a></div></form></div></div>
<style>select,textarea,input{width:100%;}</style>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}Перемещение товаров{% endblock %}
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товаров между складами <a href="{% url 'inventory:transfer-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if transfers %}<div class="table-responsive"><table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Из</th><th>В</th><th>Кол-во</th><th>Дата</th><th class="text-end">Действия</th></tr></thead><tbody>{% for t in transfers %}<tr><td>{{ t.batch.product.name }}</td><td>{{ t.from_warehouse.name }}</td><td>{{ t.to_warehouse.name }}</td><td>{{ t.quantity }}</td><td>{{ t.date|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:transfer-update' t.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a><a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></a></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="alert alert-info">Перемещений не найдено.</div>{% endif %}</div></div>
{% block inventory_content %}<div class="card"><div class="card-header"><h4 class="mb-0">Перемещение товаров между складами <a href="{% url 'inventory:transfer-create' %}" class="btn btn-primary btn-sm float-end"><i class="bi bi-plus-circle"></i> Новое</a></h4></div><div class="card-body">{% if transfers %}<div class="table-responsive"><table class="table table-hover table-sm"><thead><tr><th>Товар</th><th>Из</th><th>В</th><th>Кол-во</th><th>Дата</th><th class="text-end">Действия</th></tr></thead><tbody>{% for t in transfers %}<tr><td>{{ t.batch.product.name }}</td><td>{{ t.from_warehouse.name }}</td><td>{{ t.to_warehouse.name }}</td><td>{{ t.quantity|smart_quantity }}</td><td>{{ t.date|date:"d.m.Y" }}</td><td class="text-end"><a href="{% url 'inventory:transfer-update' t.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a><a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></a></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="alert alert-info">Перемещений не найдено.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -1,24 +1,31 @@
{% extends 'inventory/base_inventory.html' %}
{% block inventory_title %}Удаление склада{% endblock %}
{% block inventory_title %}Архивирование склада{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение удаления</h4>
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">Архивирование склада</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Вы собираетесь удалить (деактивировать) склад.
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Вы собираетесь архивировать склад <strong>"{{ warehouse.name }}"</strong>
</div>
<p class="text-muted">
Этот склад будет деактивирован и скрыт из основного списка.
<p>
<strong>Что произойдет после архивирования:</strong>
</p>
<ul>
<li>✓ Склад исчезнет из списка активных складов</li>
<li>✓ Новые документы нельзя будет создавать для этого склада</li>
<li>✓ Историю операций можно будет посмотреть в архиве</li>
</ul>
<h5>Склад: <strong>{{ warehouse.name }}</strong></h5>
<div class="alert alert-secondary mt-3">
<small>Вы всегда сможете вернуть склад, отредактировав его позже.</small>
</div>
{% if warehouse.description %}
<p class="text-muted">{{ warehouse.description }}</p>
@@ -28,8 +35,8 @@
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Подтвердить удаление
<button type="submit" class="btn btn-warning">
<i class="bi bi-archive"></i> Архивировать
</button>
<a href="{% url 'inventory:warehouse-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить

View File

@@ -67,6 +67,20 @@
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ form.is_default.id_for_label }}" name="{{ form.is_default.html_name }}"
{% if form.is_default.value %}checked{% endif %}>
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
{{ form.is_default.label }}
<small class="text-muted d-block">
Отмечьте, чтобы использовать этот склад по умолчанию при создании новых документов
</small>
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>

View File

@@ -3,6 +3,10 @@
{% block inventory_title %}Управление складами{% endblock %}
{% block inventory_content %}
<!-- Скрытое поле для CSRF токена (нужно для AJAX запросов) -->
<div style="display: none;">
{% csrf_token %}
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Список складов</h4>
@@ -17,6 +21,7 @@
<table class="table table-hover">
<thead class="table-light">
<tr>
<th style="width: 40px;"></th>
<th>Название</th>
<th>Описание</th>
<th>Статус</th>
@@ -26,8 +31,20 @@
</thead>
<tbody>
{% for warehouse in warehouses %}
<tr>
<td><strong>{{ warehouse.name }}</strong></td>
<tr {% if warehouse.is_default %}class="table-warning"{% endif %} data-warehouse-id="{{ warehouse.pk }}">
<td class="text-center">
<input type="checkbox" class="default-warehouse-checkbox"
data-warehouse-id="{{ warehouse.pk }}"
data-set-default-url="{% url 'inventory:warehouse-set-default' warehouse.pk %}"
{% if warehouse.is_default %}checked{% endif %}
style="cursor: pointer; width: 18px; height: 18px;">
</td>
<td>
<strong>{{ warehouse.name }}</strong>
{% if warehouse.is_default %}
<span class="badge bg-warning text-dark ms-2">По умолчанию</span>
{% endif %}
</td>
<td>{{ warehouse.description|truncatewords:10 }}</td>
<td>
{% if warehouse.is_active %}
@@ -95,4 +112,145 @@
{% endif %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Обработчик для галочек "По умолчанию"
const checkboxes = document.querySelectorAll('.default-warehouse-checkbox');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const warehouseId = this.dataset.warehouseId;
const setDefaultUrl = this.dataset.setDefaultUrl;
// Получаем CSRF токен из скрытого input в форме
let csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
// Если токена нет в form, ищем в meta тегах
if (!csrfToken) {
csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
}
// Если токена еще нет, ищем его в самой странице через Cookies
if (!csrfToken) {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
csrfToken = cookieValue;
}
console.log('CSRF Token:', csrfToken ? 'найден (' + csrfToken.length + ' символов)' : 'не найден');
// Если галочка установлена, отправляем запрос
if (this.checked) {
// Визуально обновляем таблицу сразу (оптимистичное обновление)
document.querySelectorAll('input.default-warehouse-checkbox').forEach(cb => {
cb.checked = false;
});
document.querySelectorAll('tr[data-warehouse-id]').forEach(tr => {
tr.classList.remove('table-warning');
tr.querySelector('.badge.bg-warning')?.remove();
});
// Отмечаем текущую строку
this.checked = true;
const currentRow = document.querySelector(`tr[data-warehouse-id="${warehouseId}"]`);
currentRow.classList.add('table-warning');
// Добавляем бейдж "По умолчанию" если его нет
const nameCell = currentRow.querySelector('td:nth-child(2)');
if (!nameCell.querySelector('.badge.bg-warning')) {
const badge = document.createElement('span');
badge.className = 'badge bg-warning text-dark ms-2';
badge.textContent = 'По умолчанию';
nameCell.appendChild(badge);
}
// Отправляем AJAX запрос на правильный URL из атрибута data
console.log('Отправляем запрос на:', setDefaultUrl);
console.log('С заголовками:', {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken ? '***' + csrfToken.slice(-10) : 'не найден'
});
const headers = {
'Content-Type': 'application/json'
};
// Добавляем CSRF токен если он найден
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
fetch(setDefaultUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify({})
})
.then(response => {
console.log('Ответ сервера:', response.status);
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP ${response.status}: ${text}`);
});
}
return response.json();
})
.then(data => {
console.log('Данные:', data);
if (data.status === 'success') {
console.log(data.message);
// Показываем уведомление
showNotification(data.message, 'success');
} else {
throw new Error(data.message);
}
})
.catch(error => {
console.error('Ошибка при запросе:', error);
// Откатываем визуальные изменения при ошибке
showNotification('Ошибка при установке склада по умолчанию: ' + error.message, 'error');
// Перезагружаем через 2 секунды
setTimeout(() => {
location.reload();
}, 2000);
});
}
});
});
// Функция для показа уведомлений
function showNotification(message, type = 'info') {
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
const alertHtml = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
const cardBody = document.querySelector('.card-body');
const alertElement = document.createElement('div');
alertElement.innerHTML = alertHtml;
cardBody.insertBefore(alertElement.firstElementChild, cardBody.firstChild);
// Автоматически скрываем через 4 секунды
setTimeout(() => {
const alert = cardBody.querySelector('.alert');
if (alert) {
alert.remove();
}
}, 4000);
}
});
</script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %}
{% block inventory_content %}
<div class="card">
@@ -36,7 +37,7 @@
<!-- Поле Количество -->
<div class="mb-3">
<label class="form-label">{{ form.quantity.label }} <span class="text-danger">*</span></label>
{{ form.quantity }}
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">{{ form.quantity.errors.0 }}</div>
{% endif %}

View File

@@ -1,4 +1,5 @@
{% extends 'inventory/base_inventory.html' %}
{% load inventory_filters %}
{% block inventory_title %}История списаний{% endblock %}
{% block inventory_content %}
<div class="card">
@@ -25,7 +26,7 @@
{% for writeoff in writeoffs %}
<tr>
<td><strong>{{ writeoff.batch.product.name }}</strong></td>
<td>{{ writeoff.quantity }} шт</td>
<td>{{ writeoff.quantity|smart_quantity }} шт</td>
<td><span class="badge bg-warning">{{ writeoff.get_reason_display }}</span></td>
<td>{{ writeoff.date|date:"d.m.Y H:i" }}</td>
<td class="text-end">

View File

@@ -0,0 +1 @@
# Template tags package for inventory app

View File

@@ -0,0 +1,98 @@
"""
Custom template filters for inventory app.
"""
from django import template
from decimal import Decimal
register = template.Library()
@register.filter(name='smart_quantity')
def smart_quantity(value):
"""
Форматирует количество товара:
- Если число целое (например 5.0, 10.000), показывает без дробной части: 5, 10
- Если число дробное (например 2.5, 3.125), убирает лишние нули: 2,5 вместо 2,500
Примеры:
5.000 -> 5
2.500 -> 2,5
3.140 -> 3,14
10.0 -> 10
Args:
value: число (int, float, Decimal или строка)
Returns:
str: отформатированное количество
"""
if value is None:
return ''
try:
# Преобразуем в Decimal для точности
if isinstance(value, str):
num = Decimal(value)
elif isinstance(value, (int, float)):
num = Decimal(str(value))
elif isinstance(value, Decimal):
num = value
else:
return str(value)
# Проверяем, является ли число целым
if num == num.to_integral_value():
# Возвращаем как целое число
return f"{int(num)}"
else:
# Убираем лишние нули справа и форматируем с запятой
# Используем normalize() для удаления лишних нулей
normalized = num.normalize()
# Форматируем с запятой вместо точки (русский формат)
result = str(normalized).replace('.', ',')
return result
except (ValueError, TypeError, ArithmeticError):
# Если не удалось преобразовать, возвращаем как есть
return str(value)
@register.filter(name='format_decimal')
def format_decimal(value, decimal_places=2):
"""
Форматирует decimal число с заданным количеством знаков после запятой.
Убирает лишние нули справа.
Args:
value: число для форматирования
decimal_places: максимальное количество знаков после запятой
Returns:
str: отформатированное число
"""
if value is None:
return ''
try:
if isinstance(value, str):
num = Decimal(value)
elif isinstance(value, (int, float)):
num = Decimal(str(value))
elif isinstance(value, Decimal):
num = value
else:
return str(value)
# Округляем до заданного количества знаков
quantize_value = Decimal(10) ** -decimal_places
rounded = num.quantize(quantize_value)
# Убираем лишние нули
normalized = rounded.normalize()
# Форматируем с запятой
result = str(normalized).replace('.', ',')
return result
except (ValueError, TypeError, ArithmeticError):
return str(value)

View File

@@ -2,7 +2,7 @@
from django.urls import path
from .views import (
# Warehouse
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView,
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
# Incoming
IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView,
# IncomingBatch
@@ -39,6 +39,7 @@ urlpatterns = [
path('warehouses/create/', WarehouseCreateView.as_view(), name='warehouse-create'),
path('warehouses/<int:pk>/edit/', WarehouseUpdateView.as_view(), name='warehouse-update'),
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
path('warehouses/<int:pk>/set-default/', SetDefaultWarehouseView.as_view(), name='warehouse-set-default'),
# ==================== INCOMING ====================
path('incoming/', IncomingListView.as_view(), name='incoming-list'),

View File

@@ -18,7 +18,7 @@ Inventory Views Package
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
from .incoming import IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
@@ -46,7 +46,7 @@ __all__ = [
# Home
'inventory_home',
# Warehouse
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView',
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
# Incoming
'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
# IncomingBatch

View File

@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, View
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from ..models import Warehouse
from ..forms import WarehouseForm
@@ -11,6 +14,7 @@ from ..forms import WarehouseForm
class WarehouseListView(LoginRequiredMixin, ListView):
"""
Список всех складов тенанта
Сортирует по is_default (по умолчанию первым), потом по названию
"""
model = Warehouse
template_name = 'inventory/warehouse/warehouse_list.html'
@@ -18,7 +22,8 @@ class WarehouseListView(LoginRequiredMixin, ListView):
paginate_by = 20
def get_queryset(self):
return Warehouse.objects.filter(is_active=True).order_by('name')
# Сортируем: сначала is_default DESC (по умолчанию первый), потом по названию
return Warehouse.objects.filter(is_active=True).order_by('-is_default', 'name')
class WarehouseCreateView(LoginRequiredMixin, CreateView):
@@ -51,16 +56,61 @@ class WarehouseUpdateView(LoginRequiredMixin, UpdateView):
class WarehouseDeleteView(LoginRequiredMixin, DeleteView):
"""
Удаление склада (мягкое удаление - деактивация)
Удаление склада (мягкое удаление - деактивация).
Вместо физического удаления из БД, устанавливаем is_active=False
"""
model = Warehouse
template_name = 'inventory/warehouse/warehouse_confirm_delete.html'
success_url = reverse_lazy('inventory:warehouse-list')
def form_valid(self, form):
# Мягкое удаление - просто деактивируем
warehouse = self.get_object()
warehouse.is_active = False
warehouse.save()
messages.success(self.request, f'Склад "{warehouse.name}" деактивирован.')
return super().form_valid(form)
def post(self, request, *args, **kwargs):
"""
Переопределяем POST метод чтобы использовать мягкое удаление
вместо стандартного физического удаления Django
"""
self.object = self.get_object()
warehouse_name = self.object.name
# Мягкое удаление - просто деактивируем склад
self.object.is_active = False
self.object.save()
messages.success(request, f'Склад "{warehouse_name}" архивирован и скрыт из списка.')
return HttpResponseRedirect(self.get_success_url())
@method_decorator(require_http_methods(["POST"]), name="dispatch")
class SetDefaultWarehouseView(LoginRequiredMixin, View):
"""
Установка склада по умолчанию
Обрабатывает POST запрос от AJAX и возвращает JSON ответ
"""
def post(self, request, pk):
"""
Установить склад с заданным pk как склад по умолчанию
"""
try:
warehouse = get_object_or_404(Warehouse, pk=pk, is_active=True)
# Установить этот склад как по умолчанию
# (метод save() в модели автоматически снимет флаг с других)
warehouse.is_default = True
warehouse.save()
return JsonResponse({
'status': 'success',
'message': f'Склад "{warehouse.name}" установлен по умолчанию',
'warehouse_id': warehouse.id,
'warehouse_name': warehouse.name
})
except Warehouse.DoesNotExist:
return JsonResponse({
'status': 'error',
'message': 'Склад не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)