feat(products): реализована система единиц продажи на фронтенде

Добавлена полноценная интеграция единиц измерения (UoM) для продажи
товаров в разных единицах с автоматическим пересчётом цен и остатков.

## Основные изменения:

### Backend
- Расширен API поиска товаров (api_views.py): добавлена сериализация sales_units
- Создан новый endpoint get_product_sales_units_api для загрузки единиц с остатками
- Добавлено поле sales_unit в OrderItemForm и SaleForm с валидацией
- Созданы CRUD views для управления единицами продажи (uom_views.py)
- Обновлена ProductForm: использует base_unit вместо устаревшего unit

### Frontend
- Создан модуль sales-units.js с функциями для работы с единицами
- Интегрирован в select2-product-search.js: автозагрузка единиц при выборе товара
- Добавлены контейнеры для единиц в order_form.html и sale_form.html
- Реализовано автоматическое обновление цены при смене единицы продажи
- При выборе базовой единицы цена возвращается к базовой цене товара

### UI
- Добавлены страницы управления единицами продажи в навбар
- Созданы шаблоны: sales_unit_list.html, sales_unit_form.html, sales_unit_delete.html
- Добавлены фильтры по товару, единице, активности и дефолтности

## Исправленные ошибки:
- Порядок инициализации: обработчики устанавливаются ДО триггера события change
- Цена корректно обновляется при выборе единицы продажи
- При выборе "Базовая единица" возвращается базовая цена товара

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 12:35:01 +03:00
parent 5b68f14bb4
commit e831c4fb6e
19 changed files with 1574 additions and 52 deletions

View File

@@ -181,7 +181,14 @@
</tr>
<tr>
<th>Единица измерения:</th>
<td>{{ product.unit }}</td>
<td>
{% if product.base_unit %}
<span class="badge bg-secondary">{{ product.base_unit.short_name }}</span>
<small class="text-muted ms-2">{{ product.base_unit.name }}</small>
{% else %}
<span class="text-muted">Не указана</span>
{% endif %}
</td>
</tr>
<tr>
<th>Себестоимость:</th>

View File

@@ -506,13 +506,13 @@
<!-- Единица измерения, Основная цена, Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-6">
{{ form.unit.label_tag }}
{{ form.unit }}
{% if form.unit.help_text %}
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
{{ form.base_unit.label_tag }}
{{ form.base_unit }}
{% if form.base_unit.help_text %}
<small class="form-text text-muted">{{ form.base_unit.help_text }}</small>
{% endif %}
{% if form.unit.errors %}
<div class="text-danger">{{ form.unit.errors }}</div>
{% if form.base_unit.errors %}
<div class="text-danger">{{ form.base_unit.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">

View File

@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}Удалить единицу продажи{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Подтверждение удаления</h4>
</div>
<div class="card-body">
<p class="mb-3">Вы действительно хотите удалить единицу продажи?</p>
<div class="alert alert-info">
<h6 class="alert-heading">Информация об единице:</h6>
<dl class="row mb-0">
<dt class="col-sm-4">Товар:</dt>
<dd class="col-sm-8">{{ sales_unit.product.name }}</dd>
<dt class="col-sm-4">Название:</dt>
<dd class="col-sm-8">{{ sales_unit.name }}</dd>
<dt class="col-sm-4">Единица:</dt>
<dd class="col-sm-8">{{ sales_unit.unit.name }} ({{ sales_unit.unit.short_name }})</dd>
<dt class="col-sm-4">Коэффициент:</dt>
<dd class="col-sm-8">{{ sales_unit.conversion_factor }}</dd>
<dt class="col-sm-4">Цена:</dt>
<dd class="col-sm-8">
{% if sales_unit.sale_price %}
<span class="text-decoration-line-through text-muted">{{ sales_unit.price }}</span>
<span class="text-danger fw-bold">{{ sales_unit.sale_price }}</span>
{% else %}
{{ sales_unit.price }}
{% endif %}
руб.
</dd>
</dl>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-circle"></i> <strong>Внимание!</strong> Это действие нельзя отменить.
</div>
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Да, удалить
</button>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,186 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
<!-- Товар и Единица измерения -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span>
</label>
{{ form.product }}
{% if form.product.help_text %}
<small class="form-text text-muted">{{ form.product.help_text }}</small>
{% endif %}
{% if form.product.errors %}
<div class="text-danger">{{ form.product.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.unit.id_for_label }}" class="form-label">
{{ form.unit.label }} <span class="text-danger">*</span>
</label>
{{ form.unit }}
{% if form.unit.help_text %}
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
{% endif %}
{% if form.unit.errors %}
<div class="text-danger">{{ form.unit.errors }}</div>
{% endif %}
</div>
</div>
<!-- Название -->
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name }}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
{% if form.name.errors %}
<div class="text-danger">{{ form.name.errors }}</div>
{% endif %}
</div>
<!-- Коэффициент конверсии -->
<div class="mb-3">
<label for="{{ form.conversion_factor.id_for_label }}" class="form-label">
{{ form.conversion_factor.label }} <span class="text-danger">*</span>
</label>
{{ form.conversion_factor }}
{% if form.conversion_factor.help_text %}
<small class="form-text text-muted d-block">
<i class="bi bi-info-circle"></i> {{ form.conversion_factor.help_text }}
</small>
{% endif %}
{% if form.conversion_factor.errors %}
<div class="text-danger">{{ form.conversion_factor.errors }}</div>
{% endif %}
</div>
<!-- Цена и Цена со скидкой -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.price.id_for_label }}" class="form-label">
{{ form.price.label }} <span class="text-danger">*</span>
</label>
<div class="input-group">
{{ form.price }}
<span class="input-group-text"></span>
</div>
{% if form.price.help_text %}
<small class="form-text text-muted">{{ form.price.help_text }}</small>
{% endif %}
{% if form.price.errors %}
<div class="text-danger">{{ form.price.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.sale_price.id_for_label }}" class="form-label">
{{ form.sale_price.label }}
</label>
<div class="input-group">
{{ form.sale_price }}
<span class="input-group-text"></span>
</div>
{% if form.sale_price.help_text %}
<small class="form-text text-muted">{{ form.sale_price.help_text }}</small>
{% endif %}
{% if form.sale_price.errors %}
<div class="text-danger">{{ form.sale_price.errors }}</div>
{% endif %}
</div>
</div>
<!-- Минимальное количество и Шаг -->
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ form.min_quantity.id_for_label }}" class="form-label">
{{ form.min_quantity.label }} <span class="text-danger">*</span>
</label>
{{ form.min_quantity }}
{% if form.min_quantity.help_text %}
<small class="form-text text-muted">{{ form.min_quantity.help_text }}</small>
{% endif %}
{% if form.min_quantity.errors %}
<div class="text-danger">{{ form.min_quantity.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.quantity_step.id_for_label }}" class="form-label">
{{ form.quantity_step.label }} <span class="text-danger">*</span>
</label>
{{ form.quantity_step }}
{% if form.quantity_step.help_text %}
<small class="form-text text-muted">{{ form.quantity_step.help_text }}</small>
{% endif %}
{% if form.quantity_step.errors %}
<div class="text-danger">{{ form.quantity_step.errors }}</div>
{% endif %}
</div>
</div>
<!-- Флаги -->
<div class="mb-3">
<div class="form-check mb-2">
{{ form.is_default }}
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
{{ form.is_default.label }}
</label>
{% if form.is_default.help_text %}
<br><small class="form-text text-muted">{{ form.is_default.help_text }}</small>
{% endif %}
</div>
<div class="form-check mb-2">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{{ form.is_active.label }}
</label>
</div>
</div>
<!-- Позиция -->
<div class="mb-3">
<label for="{{ form.position.id_for_label }}" class="form-label">
{{ form.position.label }}
</label>
{{ form.position }}
<small class="form-text text-muted">Чем меньше число, тем выше в списке</small>
</div>
<!-- Кнопки -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ submit_text }}
</button>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends 'base.html' %}
{% block title %}Единицы продажи товаров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-11">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-box-seam text-primary"></i> Единицы продажи товаров</h4>
<a href="{% url 'products:sales-unit-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Добавить единицу
</a>
</div>
<!-- Поиск и фильтры -->
<form method="get" class="mb-3">
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по товару, артикулу, названию...">
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="unit" onchange="this.form.submit()">
<option value="">Все единицы</option>
{% for unit in all_units %}
<option value="{{ unit.id }}" {% if unit_filter == unit.id|stringformat:"s" %}selected{% endif %}>
{{ unit.short_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="is_active" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>Активные</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>Неактивные</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select form-select-sm" name="is_default" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_default_filter == 'true' %}selected{% endif %}>По умолчанию</option>
<option value="false" {% if is_default_filter == 'false' %}selected{% endif %}>Не по умолч.</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">
<i class="bi bi-search"></i> Найти
</button>
</div>
</div>
</form>
<!-- Статистика -->
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> Всего единиц продажи: <strong>{{ total_sales_units }}</strong>
</div>
<!-- Список единиц продажи -->
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 25%;">Товар</th>
<th style="width: 15%;">Название единицы</th>
<th style="width: 10%;" class="text-center">Единица</th>
<th style="width: 10%;" class="text-end">Коэфф.</th>
<th style="width: 12%;" class="text-end">Цена</th>
<th style="width: 8%;" class="text-center">Мин.</th>
<th style="width: 8%;" class="text-center">Шаг</th>
<th style="width: 6%;" class="text-center">По умолч.</th>
<th style="width: 6%;" class="text-center">Статус</th>
<th style="width: 10%;" class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for su in page_obj %}
<tr>
<td>
<a href="{% url 'products:product-detail' su.product.pk %}" class="text-decoration-none">
{{ su.product.name }}
</a>
{% if su.product.sku %}
<br><small class="text-muted">{{ su.product.sku }}</small>
{% endif %}
</td>
<td>{{ su.name }}</td>
<td class="text-center">
<span class="badge bg-secondary">{{ su.unit.short_name }}</span>
</td>
<td class="text-end"><code>{{ su.conversion_factor }}</code></td>
<td class="text-end">
{% if su.sale_price %}
<span class="text-decoration-line-through text-muted">{{ su.price }}</span>
<span class="text-danger fw-bold">{{ su.sale_price }}</span>
{% else %}
{{ su.price }}
{% endif %}
<small class="text-muted">руб.</small>
</td>
<td class="text-center">
<small>{{ su.min_quantity }}</small>
</td>
<td class="text-center">
<small>{{ su.quantity_step }}</small>
</td>
<td class="text-center">
{% if su.is_default %}
<i class="bi bi-check-circle-fill text-success" title="По умолчанию"></i>
{% else %}
<i class="bi bi-circle text-muted" title="Не по умолчанию"></i>
{% endif %}
</td>
<td class="text-center">
{% if su.is_active %}
<span class="badge bg-success">Акт.</span>
{% else %}
<span class="badge bg-secondary">Неакт.</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'products:sales-unit-update' su.pk %}" class="btn btn-outline-secondary btn-sm" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'products:sales-unit-delete' su.pk %}" class="btn btn-outline-danger btn-sm" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if page_obj.has_other_pages %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&laquo;</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if unit_filter %}&unit={{ unit_filter }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}{% if is_default_filter %}&is_default={{ is_default_filter }}{% endif %}">&raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-box-seam fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Единиц продажи пока нет</p>
<p class="small">Добавьте единицы продажи для товаров через форму выше</p>
</div>
{% endif %}
<!-- Ссылка обратно на единицы измерения -->
<div class="mt-4 p-3 bg-light rounded">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><i class="bi bi-rulers"></i> Справочник единиц измерения</h6>
<small class="text-muted">Управление базовыми единицами измерения</small>
</div>
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-primary btn-sm">
Перейти <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,123 @@
{% extends 'base.html' %}
{% block title %}Единицы измерения{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-10">
<!-- Заголовок -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h4 class="mb-0"><i class="bi bi-rulers text-primary"></i> Единицы измерения</h4>
<a href="{% url 'admin:products_unitofmeasure_add' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Добавить единицу
</a>
</div>
<!-- Поиск и фильтры -->
<form method="get" class="d-flex gap-2 mb-3">
<input type="text" class="form-control form-control-sm" name="q" value="{{ search_query }}" placeholder="Поиск по коду, названию...">
<select class="form-select form-select-sm" name="is_active" style="width: auto;" onchange="this.form.submit()">
<option value="">Все</option>
<option value="true" {% if is_active_filter == 'true' %}selected{% endif %}>Активные</option>
<option value="false" {% if is_active_filter == 'false' %}selected{% endif %}>Неактивные</option>
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-search"></i></button>
</form>
<!-- Статистика -->
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> Всего единиц: <strong>{{ total_units }}</strong>
</div>
<!-- Список единиц измерения -->
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width: 100px;">Код</th>
<th>Название</th>
<th style="width: 120px;">Краткое</th>
<th style="width: 100px;" class="text-center">Позиция</th>
<th style="width: 120px;" class="text-center">Использований</th>
<th style="width: 100px;" class="text-center">Статус</th>
<th style="width: 120px;" class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for unit in page_obj %}
<tr>
<td><code>{{ unit.code }}</code></td>
<td>{{ unit.name }}</td>
<td><span class="badge bg-secondary">{{ unit.short_name }}</span></td>
<td class="text-center">{{ unit.position }}</td>
<td class="text-center">
<span class="badge {% if unit.usage_count > 0 %}bg-success{% else %}bg-secondary{% endif %}">
{{ unit.usage_count }}
</span>
</td>
<td class="text-center">
{% if unit.is_active %}
<span class="badge bg-success">Активна</span>
{% else %}
<span class="badge bg-secondary">Неактивна</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'admin:products_unitofmeasure_change' unit.pk %}" class="btn btn-outline-secondary" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if page_obj.has_other_pages %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">&laquo;</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">&raquo;</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-rulers fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Единиц измерения пока нет</p>
</div>
{% endif %}
<!-- Ссылка на единицы продажи -->
<div class="mt-4 p-3 bg-light rounded">
<div class="d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><i class="bi bi-box-seam"></i> Единицы продажи товаров</h6>
<small class="text-muted">Настройка единиц продажи для конкретных товаров</small>
</div>
<a href="{% url 'products:sales-unit-list' %}" class="btn btn-outline-primary btn-sm">
Перейти <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}