refactor(inventory): remove individual writeoff views and templates, shift to document-based writeoffs

- Remove WriteOffForm from forms.py and add comment directing to WriteOffDocumentForm
- Update navigation templates to remove writeoff links and sections
- Add 'Сумма' column to sale list with multiplication filter
- Delete writeoff-related templates (list, form, confirm delete)
- Add 'multiply' filter to inventory_filters.py for calculations
- Comment out writeoff URLs in urls.py, keeping WriteOff model for automatic creation
- Remove WriteOff views from __init__.py and delete writeoff.py view file

This change simplifies writeoff management by removing direct individual writeoff operations and enforcing use of WriteOffDocument for all writeoffs, with WriteOff records created automatically upon document processing.
This commit is contained in:
2025-12-27 01:04:41 +03:00
parent 1eaee7de5e
commit 44d115b356
11 changed files with 59 additions and 535 deletions

View File

@@ -26,7 +26,6 @@
<li><a class="dropdown-item" href="{% url 'inventory:incoming-list' %}">Поступления</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:sale-list' %}">Продажи</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:inventory-list' %}">Инвентаризация</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:writeoff-list' %}">Списания</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:writeoff-document-list' %}">Документы списания</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
<li><hr class="dropdown-divider"></li>

View File

@@ -92,19 +92,6 @@
</a>
</div>
<!-- Приходы -->
<div class="col-12 col-md-6 col-lg-4">
<a href="{% url 'inventory:incoming-list' %}" class="card-main-operation h-100 text-decoration-none">
<div class="card-body p-4">
<div class="text-center">
<i class="bi bi-arrow-down-square text-muted mb-3" style="font-size: 2rem;"></i>
<h6 class="mb-2 text-dark fw-medium">Приходы</h6>
<small class="text-muted">Поступление товара</small>
</div>
</div>
</a>
</div>
<!-- Продажи -->
<div class="col-12 col-md-6 col-lg-4">
<a href="{% url 'inventory:sale-list' %}" class="card-main-operation h-100 text-decoration-none">
@@ -118,19 +105,6 @@
</a>
</div>
<!-- Списания -->
<div class="col-12 col-md-6 col-lg-4">
<a href="{% url 'inventory:writeoff-list' %}" class="card-main-operation h-100 text-decoration-none">
<div class="card-body p-4">
<div class="text-center">
<i class="bi bi-x-circle text-muted mb-3" style="font-size: 2rem;"></i>
<h6 class="mb-2 text-dark fw-medium">Списания</h6>
<small class="text-muted">Списание товара</small>
</div>
</div>
</a>
</div>
<!-- Перемещения -->
<div class="col-12 col-md-6 col-lg-4">
<a href="{% url 'inventory:transfer-list' %}" class="card-main-operation h-100 text-decoration-none">

View File

@@ -20,6 +20,7 @@
<th>Склад</th>
<th>Количество</th>
<th>Цена продажи</th>
<th>Сумма</th>
<th>Заказ</th>
<th>Статус</th>
<th>Дата</th>
@@ -33,6 +34,7 @@
<td>{{ sale.warehouse.name }}</td>
<td>{{ sale.quantity|smart_quantity }} шт</td>
<td>{{ sale.sale_price }} руб.</td>
<td><strong>{{ sale.quantity|multiply:sale.sale_price|format_decimal:2 }} руб.</strong></td>
<td>
{% if sale.order %}
<code>{{ sale.order.order_number }}</code>

View File

@@ -1,12 +0,0 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% block inventory_title %}Отмена списания{% endblock %}
{% block breadcrumb_current %}Списания{% endblock %}
{% block inventory_content %}
<div class="card border-danger">
<div class="card-header bg-danger text-white"><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>
<form method="post"><{% csrf_token %}<div class="d-flex gap-2"><button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Подтвердить</button><a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary"><i class="bi bi-x-circle"></i> Отмена</a></div></form>
</div>
</div>
{% endblock %}

View File

@@ -1,335 +0,0 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% load static inventory_filters %}
{% block inventory_title %}{% if form.instance.pk %}Редактирование списания{% else %}Новое списание{% endif %}{% endblock %}
{% block breadcrumb_current %}Списания{% endblock %}
{% block inventory_content %}
<!-- CSS для компонента -->
<link rel="stylesheet" href="{% static 'products/css/product-search-picker.css' %}">
<div class="row">
<!-- Левая колонка: поиск товаров -->
<div class="col-lg-6 mb-4">
{% include 'products/components/product_search_picker.html' with container_id='writeoff-product-picker' title='Найти товар для списания' filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' multi_select=False content_height='350px' %}
</div>
<!-- Правая колонка: форма списания -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-clipboard-minus text-danger"></i>
{% if form.instance.pk %}Редактирование{% else %}Создание{% endif %} списания
</h5>
</div>
<div class="card-body">
<!-- Ошибки формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong><i class="bi bi-exclamation-triangle"></i> Ошибка:</strong>
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Выбранный товар -->
<div id="selected-product-info" class="alert alert-info mb-3" style="display: none;">
<div class="d-flex align-items-center">
<img id="selected-product-photo" src="" alt="" class="rounded me-3" style="width: 50px; height: 50px; object-fit: cover; display: none;">
<div class="flex-grow-1">
<strong id="selected-product-name"></strong>
<div class="small text-muted" id="selected-product-sku"></div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selected-product">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<form method="post" novalidate id="writeoff-form">
{% csrf_token %}
<!-- Скрытое поле для хранения выбранного product_id -->
<input type="hidden" id="selected-product-id" name="selected_product_id" value="">
<!-- Поле Партия -->
<div class="mb-3">
<label class="form-label">{{ form.batch.label }} <span class="text-danger">*</span></label>
{{ form.batch }}
{% if form.batch.errors %}
<div class="invalid-feedback d-block">{{ form.batch.errors.0 }}</div>
{% endif %}
<small class="text-muted" id="batch-hint">
<i class="bi bi-info-circle"></i> Сначала выберите товар слева, затем здесь появятся его партии
</small>
<!-- Информация об остатке партии -->
<div id="batch-info" class="mt-2 p-2 bg-light border rounded" style="display:none;">
<small class="text-muted">
Остаток в партии: <strong id="batch-quantity">0</strong> шт
</small>
</div>
</div>
<!-- Поле Количество -->
<div class="mb-3">
<label class="form-label">{{ form.quantity.label }} <span class="text-danger">*</span></label>
{{ form.quantity|smart_quantity }}
{% if form.quantity.errors %}
<div class="invalid-feedback d-block">{{ form.quantity.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Введите количество товара для списания
</small>
<!-- Предупреждение о превышении остатка -->
<div id="quantity-warning" class="alert alert-warning mt-2" style="display:none;">
<i class="bi bi-exclamation-circle"></i>
<strong>Внимание!</strong> Вы пытаетесь списать <strong id="warning-qty">0</strong> шт,
а в партии только <strong id="warning-batch">0</strong> шт.
Недостаток: <strong id="warning-shortage" class="text-danger">0</strong> шт.
</div>
</div>
<!-- Поле Причина -->
<div class="mb-3">
<label class="form-label">{{ form.reason.label }}</label>
{{ form.reason }}
{% if form.reason.errors %}
<div class="invalid-feedback d-block">{{ form.reason.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Номер документа -->
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">{{ form.document_number.errors.0 }}</div>
{% endif %}
</div>
<!-- Поле Примечания -->
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<!-- Кнопки действия -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-check-circle"></i> Сохранить
</button>
<a href="{% url 'inventory:writeoff-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<style>
select, textarea, input[type="text"], input[type="number"] {
width: 100%;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875em;
}
#selected-product-info {
border-left: 4px solid #0d6efd;
}
</style>
<!-- JS для компонента -->
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Элементы формы
const batchSelect = document.querySelector('#id_batch');
const quantityInput = document.querySelector('#id_quantity');
const batchInfo = document.getElementById('batch-info');
const batchQuantitySpan = document.getElementById('batch-quantity');
const quantityWarning = document.getElementById('quantity-warning');
const warningQty = document.getElementById('warning-qty');
const warningBatch = document.getElementById('warning-batch');
const warningShortage = document.getElementById('warning-shortage');
const batchHint = document.getElementById('batch-hint');
// Элементы отображения выбранного товара
const selectedProductInfo = document.getElementById('selected-product-info');
const selectedProductName = document.getElementById('selected-product-name');
const selectedProductSku = document.getElementById('selected-product-sku');
const selectedProductPhoto = document.getElementById('selected-product-photo');
const selectedProductIdInput = document.getElementById('selected-product-id');
const clearSelectedBtn = document.getElementById('clear-selected-product');
// Сохраняем все опции партий для фильтрации
const allBatchOptions = Array.from(batchSelect.options).map(opt => ({
value: opt.value,
text: opt.text,
productName: opt.text.split(' на ')[0] // Извлекаем название товара
}));
// Инициализация компонента поиска товаров
const picker = ProductSearchPicker.init('#writeoff-product-picker', {
onAddSelected: function(products, instance) {
if (products.length > 0) {
selectProduct(products[0]);
instance.clearSelection();
}
}
});
// Функция выбора товара
function selectProduct(product) {
const productId = String(product.id).replace('product_', '');
const productName = product.text || product.name || '';
// Показываем информацию о выбранном товаре
selectedProductName.textContent = productName;
selectedProductSku.textContent = product.sku || '';
selectedProductIdInput.value = productId;
if (product.photo_url) {
selectedProductPhoto.src = product.photo_url;
selectedProductPhoto.style.display = 'block';
} else {
selectedProductPhoto.style.display = 'none';
}
selectedProductInfo.style.display = 'block';
// Фильтруем партии по выбранному товару
filterBatchesByProduct(productName);
// Скрываем подсказку
batchHint.style.display = 'none';
}
// Функция очистки выбора товара
function clearSelectedProduct() {
selectedProductInfo.style.display = 'none';
selectedProductName.textContent = '';
selectedProductSku.textContent = '';
selectedProductPhoto.style.display = 'none';
selectedProductIdInput.value = '';
// Показываем все партии
showAllBatches();
// Показываем подсказку
batchHint.style.display = 'block';
}
// Фильтрация партий по названию товара
function filterBatchesByProduct(productName) {
// Очищаем select
batchSelect.innerHTML = '<option value="">---------</option>';
// Добавляем только партии выбранного товара
const normalizedName = productName.toLowerCase().trim();
allBatchOptions.forEach(function(opt) {
if (opt.value && opt.productName.toLowerCase().trim().includes(normalizedName.split(' (')[0])) {
const option = document.createElement('option');
option.value = opt.value;
option.text = opt.text;
batchSelect.appendChild(option);
}
});
// Если найдена только одна партия, выбираем её автоматически
if (batchSelect.options.length === 2) {
batchSelect.selectedIndex = 1;
updateBatchInfo();
}
}
// Показать все партии
function showAllBatches() {
batchSelect.innerHTML = '<option value="">---------</option>';
allBatchOptions.forEach(function(opt) {
if (opt.value) {
const option = document.createElement('option');
option.value = opt.value;
option.text = opt.text;
batchSelect.appendChild(option);
}
});
}
// Очистка выбора товара
clearSelectedBtn.addEventListener('click', clearSelectedProduct);
// ===== Существующая логика для партий =====
// Функция для получения остатка партии
function getBatchQuantity() {
if (!batchSelect.value) {
batchInfo.style.display = 'none';
quantityWarning.style.display = 'none';
return null;
}
// Получаем текст option и парсим остаток
const selectedOption = batchSelect.options[batchSelect.selectedIndex];
const optionText = selectedOption.text;
// Пытаемся найти количество в скобках (формат: "Product - Остаток: X шт")
const match = optionText.match(/Остаток:\s*(\d+(?:[.,]\d+)?)/);
if (match) {
const qty = parseFloat(match[1].replace(',', '.'));
return qty;
}
return null;
}
// Функция для обновления информации и предупреждений
function updateBatchInfo() {
const batchQty = getBatchQuantity();
if (batchQty !== null) {
batchQuantitySpan.textContent = batchQty;
batchInfo.style.display = 'block';
} else {
batchInfo.style.display = 'none';
quantityWarning.style.display = 'none';
}
}
// Функция для проверки количества
function checkQuantity() {
const batchQty = getBatchQuantity();
const qty = parseFloat(quantityInput.value) || 0;
if (batchQty !== null && qty > 0) {
if (qty > batchQty) {
warningQty.textContent = qty;
warningBatch.textContent = batchQty;
warningShortage.textContent = (qty - batchQty).toFixed(3);
quantityWarning.style.display = 'block';
} else {
quantityWarning.style.display = 'none';
}
} else {
quantityWarning.style.display = 'none';
}
}
// События
batchSelect.addEventListener('change', updateBatchInfo);
quantityInput.addEventListener('input', checkQuantity);
// Инициализация
updateBatchInfo();
});
</script>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %}
{% block inventory_title %}История списаний{% endblock %}
{% block breadcrumb_current %}Списания{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Списания товара</h4>
<a href="{% url 'inventory:writeoff-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новое списание
</a>
</div>
<div class="card-body">
{% if writeoffs %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Количество</th>
<th>Причина</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for writeoff in writeoffs %}
<tr>
<td><strong>{{ writeoff.batch.product.name }}</strong></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">
<a href="{% url 'inventory:writeoff-update' writeoff.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'inventory:writeoff-delete' writeoff.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 %}