This commit is contained in:
2025-11-04 11:00:05 +03:00
parent 706ee5d8e8
commit b24d5bcdee
13 changed files with 1383 additions and 72 deletions

View File

@@ -0,0 +1,462 @@
{% extends 'base.html' %}
{% block title %}Создать перемещение товара{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3" style="max-width: 1400px;">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
<li class="breadcrumb-item active">Новое перемещение</li>
</ol>
</nav>
<!-- Errors -->
<div id="errorContainer"></div>
<form method="post" id="transferForm">
{% csrf_token %}
<div class="row g-3">
<!-- ЛЕВАЯ КОЛОНКА: Основная информация -->
<div class="col-lg-8">
<!-- Склад-отгрузки и Склад-приемки -->
<div class="row g-2 mb-3">
<div class="col-md-6">
<label for="{{ form.from_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-box-seam-out me-1"></i>{{ form.from_warehouse.label }}
</label>
{{ form.from_warehouse }}
{% if form.from_warehouse.errors %}
<div class="text-danger small mt-1">{{ form.from_warehouse.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.to_warehouse.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-box-seam-in me-1"></i>{{ form.to_warehouse.label }}
</label>
{{ form.to_warehouse }}
{% if form.to_warehouse.errors %}
<div class="text-danger small mt-1">{{ form.to_warehouse.errors }}</div>
{% endif %}
</div>
</div>
<!-- Примечания -->
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label small text-muted mb-1">
<i class="bi bi-chat-dots me-1"></i>{{ form.notes.label }}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">{{ form.notes.errors }}</div>
{% endif %}
</div>
<!-- ТОВАРЫ -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-3 d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-bag-check me-1"></i>Товары для перемещения</h6>
<button type="button" class="btn btn-sm btn-success" id="addProductBtn">
<i class="bi bi-plus-lg me-1"></i>Добавить товар
</button>
</div>
<div class="card-body p-0">
<div id="productsTableContainer">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="productsTable">
<thead>
<tr class="border-bottom">
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2" style="width: 150px;">Доступно</th>
<th scope="col" class="px-3 py-2" style="text-align: right; width: 150px;">Кол-во</th>
<th scope="col" class="px-3 py-2 text-center" style="width: 50px;">Действие</th>
</tr>
</thead>
<tbody id="productsBody">
<!-- JavaScript добавляет строки сюда -->
</tbody>
</table>
</div>
<div id="emptyState" class="text-center py-4 text-muted">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">Товары не добавлены. Нажмите "Добавить товар" для начала</p>
</div>
</div>
</div>
</div>
<!-- Итоги -->
<div class="row mt-3 g-2">
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Позиций</p>
<p class="h5 mb-0" id="totalItems">0</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Всего товара</p>
<p class="h5 mb-0"><span id="totalQuantity">0</span> шт</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 bg-light">
<div class="card-body p-3">
<p class="text-muted small mb-1">Статус</p>
<p class="h5 mb-0">
<span id="formStatus" class="badge bg-secondary">Готово</span>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: Справочная информация -->
<div class="col-lg-4">
<!-- Справка -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Информация</h6>
<div class="alert alert-info alert-sm" style="font-size: 0.875rem;">
<p class="mb-1"><strong>Логика FIFO:</strong></p>
<p class="mb-0">Товар перемещается из старых партий первыми. Стоимость партий сохраняется.</p>
</div>
</div>
</div>
<!-- Логирование -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2">
<h6 class="mb-0 text-muted"><i class="bi bi-journal-text me-1"></i>Логирование</h6>
</div>
<div class="card-body p-3" style="max-height: 300px; overflow-y: auto;">
<div id="logContainer">
<p class="text-muted small mb-0">Логи операций появятся здесь...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sticky Footer -->
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
<button type="submit" class="btn btn-success px-4" id="submitBtn">
<i class="bi bi-check-circle me-1"></i>Создать перемещение
</button>
</div>
<!-- Скрытое поле для JSON товаров -->
<input type="hidden" name="products_json" id="products_json" value="[]">
</form>
</div>
<style>
.breadcrumb-sm {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
.alert-sm {
padding: 0.5rem 0.75rem;
margin-bottom: 0;
}
.sticky-bottom {
position: sticky;
bottom: 0;
z-index: 1020;
}
#productsTable input[type="number"] {
width: 100%;
}
.product-row-delete {
animation: fadeOut 0.3s;
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.log-entry {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
font-size: 0.875rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.info {
color: #0c5460;
background-color: #d1ecf1;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
.log-entry.error {
color: #721c24;
background-color: #f8d7da;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
.log-entry.success {
color: #155724;
background-color: #d4edda;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('transferForm');
const productsBody = document.getElementById('productsBody');
const productsJson = document.getElementById('products_json');
const emptyState = document.getElementById('emptyState');
const addProductBtn = document.getElementById('addProductBtn');
const totalItemsEl = document.getElementById('totalItems');
const totalQuantityEl = document.getElementById('totalQuantity');
const logContainer = document.getElementById('logContainer');
const fromWarehouseSelect = document.querySelector('[name="from_warehouse"]');
const toWarehouseSelect = document.querySelector('[name="to_warehouse"]');
let products = [];
let rowCounter = 0;
// Логирование
function log(message, type = 'info') {
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logContainer.appendChild(entry);
logContainer.scrollTop = logContainer.scrollHeight;
}
// Добавить новую строку товара
function addProductRow() {
const rowId = rowCounter++;
const html = `
<tr class="product-row" data-row-id="${rowId}">
<td class="px-3 py-2">
<select class="form-control form-control-sm product-select" name="product_${rowId}" required>
<option value="">-- Выберите товар --</option>
{% for product in products %}
<option value="{{ product.id }}">{{ product.name }} ({{ product.sku }})</option>
{% empty %}
<option value="">Товары не найдены. Создайте товар в системе.</option>
{% endfor %}
</select>
</td>
<td class="px-3 py-2">
<small class="text-muted available-qty" data-row-id="${rowId}">--</small>
</td>
<td class="px-3 py-2">
<input type="number" class="form-control form-control-sm quantity-input"
name="quantity_${rowId}" value="1" step="0.001" min="0" required>
</td>
<td class="px-3 py-2 text-center">
<button type="button" class="btn btn-sm btn-link text-danger p-0 delete-row-btn"
data-row-id="${rowId}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
const tr = document.createElement('tr');
tr.innerHTML = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/)[0].replace(html.match(/<tr[^>]*>/)[0], '').slice(0, -5);
// Простой способ: добавляем HTML напрямую
const row = productsBody.insertAdjacentHTML('beforeend', html);
updateUI();
log(`Добавлена новая строка товара (#${rowId})`, 'info');
}
// Функция для получения доступного количества товара
function fetchProductStock(productId, warehouseId, rowId) {
const availableQtyEl = document.querySelector(`.available-qty[data-row-id="${rowId}"]`);
if (!availableQtyEl) {
console.warn(`Element not found for rowId: ${rowId}`);
return;
}
if (!productId || !warehouseId) {
availableQtyEl.textContent = '--';
return;
}
const url = `/inventory/api/product-stock/?product_id=${productId}&warehouse_id=${warehouseId}`;
console.log(`Fetching stock from: ${url}`);
fetch(url)
.then(response => {
console.log('Response status:', response.status);
return response.json();
})
.then(data => {
console.log('Response data:', data);
if (data.success) {
availableQtyEl.textContent = `${data.quantity} шт`;
} else {
availableQtyEl.textContent = `Ошибка: ${data.error || 'неизвестная ошибка'}`;
console.error('API error:', data.error);
}
})
.catch(error => {
console.error('Fetch error:', error);
availableQtyEl.textContent = '--';
});
}
// Удалить строку товара
productsBody.addEventListener('click', function(e) {
if (e.target.closest('.delete-row-btn')) {
const rowId = e.target.closest('.delete-row-btn').dataset.rowId;
const row = document.querySelector(`[data-row-id="${rowId}"]`);
if (row) {
row.classList.add('product-row-delete');
setTimeout(() => {
row.remove();
updateUI();
log(`Удалена строка товара (#${rowId})`, 'info');
}, 300);
}
}
});
// Обновить JSON и UI
function updateUI() {
products = [];
let totalQty = 0;
productsBody.querySelectorAll('tr.product-row').forEach(row => {
const rowId = row.dataset.rowId;
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
if (productSelect.value && quantityInput.value) {
const productId = parseInt(productSelect.value);
const quantity = parseFloat(quantityInput.value);
if (quantity > 0) {
products.push({
product_id: productId,
quantity: quantity
});
totalQty += quantity;
}
}
});
productsJson.value = JSON.stringify(products);
totalItemsEl.textContent = products.length;
totalQuantityEl.textContent = totalQty.toFixed(3);
// Показываем/скрываем пустое состояние
if (products.length === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
}
// Обработчики событий
addProductBtn.addEventListener('click', function(e) {
e.preventDefault();
addProductRow();
});
// Когда выбор склада отгрузки изменяется, обновляем доступные количества для всех товаров
fromWarehouseSelect.addEventListener('change', function() {
const warehouseId = this.value;
productsBody.querySelectorAll('.product-row').forEach(row => {
const rowId = row.dataset.rowId;
const productSelect = row.querySelector('.product-select');
const productId = productSelect.value;
if (productId && warehouseId) {
fetchProductStock(productId, warehouseId, rowId);
}
});
});
// Обновляем JSON при изменении значений
productsBody.addEventListener('change', function(e) {
updateUI();
// Если изменилось поле выбора товара, загружаем доступное количество
if (e.target.classList.contains('product-select')) {
const row = e.target.closest('.product-row');
const rowId = row.dataset.rowId;
const productId = e.target.value;
const warehouseId = fromWarehouseSelect.value;
console.log('Product selected:', {productId, warehouseId, rowId});
log(`Выбран товар ID ${productId}`, 'info');
fetchProductStock(productId, warehouseId, rowId);
}
});
productsBody.addEventListener('input', updateUI);
// Валидация формы перед отправкой
form.addEventListener('submit', function(e) {
e.preventDefault();
// Проверяем что товары есть
if (products.length === 0) {
log('Ошибка: необходимо добавить хотя бы один товар', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо добавить хотя бы один товар для перемещения.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
// Проверяем выбор складов
if (!fromWarehouseSelect.value || !toWarehouseSelect.value) {
log('Ошибка: необходимо выбрать оба склада', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Необходимо выбрать склад-отгрузки и склад-приемки.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
if (fromWarehouseSelect.value === toWarehouseSelect.value) {
log('Ошибка: склады не должны быть одинаковыми', 'error');
const errorDiv = document.getElementById('errorContainer');
errorDiv.innerHTML = '<div class="alert alert-danger alert-dismissible fade show">Склад-отгрузки и склад-приемки должны быть разными.<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
return;
}
log(`Отправка документа с ${products.length} товарами`, 'success');
form.submit();
});
// Инициальное состояние
updateUI();
log('Форма инициализирована', 'info');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,155 @@
{% extends 'base.html' %}
{% block title %}Документ перемещения {{ transfer_batch.document_number }}{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-3">
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'inventory:transfer-list' %}">Перемещения</a></li>
<li class="breadcrumb-item active">{{ transfer_batch.document_number }}</li>
</ol>
</nav>
<div class="row g-3">
<!-- Основная информация о документе -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-3">
<h5 class="mb-0">
<i class="bi bi-arrow-left-right me-2"></i>{{ transfer_batch.document_number }}
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<p class="text-muted small mb-1">Склад-отгрузки</p>
<p class="fw-semibold">{{ transfer_batch.from_warehouse.name }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Склад-приемки</p>
<p class="fw-semibold">{{ transfer_batch.to_warehouse.name }}</p>
</div>
</div>
{% if transfer_batch.notes %}
<div class="mb-3">
<p class="text-muted small mb-1">Примечания</p>
<p>{{ transfer_batch.notes }}</p>
</div>
{% endif %}
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-1">Дата создания</p>
<p class="fw-semibold">{{ transfer_batch.created_at|date:"d.m.Y H:i" }}</p>
</div>
<div class="col-md-6">
<p class="text-muted small mb-1">Последнее обновление</p>
<p class="fw-semibold">{{ transfer_batch.updated_at|date:"d.m.Y H:i" }}</p>
</div>
</div>
</div>
</div>
<!-- Таблица товаров -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-3">
<h6 class="mb-0"><i class="bi bi-table me-2"></i>Товары в документе</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr class="border-bottom">
<th scope="col" class="px-3 py-2">Товар</th>
<th scope="col" class="px-3 py-2" style="text-align: right;">Количество</th>
<th scope="col" class="px-3 py-2" style="text-align: right;">Цена партии</th>
<th scope="col" class="px-3 py-2">Исходная партия</th>
<th scope="col" class="px-3 py-2">Новая партия</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
<td class="px-3 py-2">
<span class="badge bg-secondary">{{ item.batch.id }}</span>
</td>
<td class="px-3 py-2">
{% if item.new_batch %}
<span class="badge bg-success">{{ item.new_batch.id }}</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="px-3 py-2 text-muted text-center">
Товаров не найдено
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Сводка справа -->
<div class="col-lg-4">
<!-- Статистика -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-1"></i>Статистика</h6>
<div class="mb-3 p-2 rounded" style="background: #f8f9fa;">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Позиций:</span>
<span class="fw-semibold">{{ total_items }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Всего товара:</span>
<span class="fw-semibold">{{ total_qty }} шт</span>
</div>
</div>
</div>
</div>
<!-- Действия -->
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-gear me-1"></i>Действия</h6>
<div class="d-grid gap-2">
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
</a>
<a href="{% url 'inventory:transfer-delete' transfer_batch.id %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.breadcrumb-sm {
font-size: 0.875rem;
padding: 0.5rem 0;
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
</style>
{% endblock %}

View File

@@ -1,5 +1,55 @@
{% 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|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>
{% 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>
<a href="{% url 'inventory:transfer-detail' t.pk %}">{{ t.document_number }}</a>
</td>
<td>{{ t.from_warehouse.name }}</td>
<td>{{ t.to_warehouse.name }}</td>
<td>{{ t.items.count }}</td>
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">Перемещений не найдено.</div>
{% endif %}
</div>
</div>
{% endblock %}