refactor: мигрировать на новую систему документов поступления

Удалена старая одноэтапная система incoming и оставлена только новая
двухэтапная система IncomingDocument (черновик → проведение).

Изменения:
- URL структура изменена с /incoming-documents/ на /incoming/
- URL names: incoming-document-* → incoming-*
- Удалены старые views, forms, templates для Incoming/IncomingBatch
- Обновлена навигация и все ссылки в шаблонах
- Модели IncomingBatch/Incoming сохранены как внутренняя архитектура

Удалено ~1590 строк кода:
- inventory/views/incoming.py (389 строк)
- inventory/forms.py (206 строк старых форм)
- inventory/admin.py (56 строк)
- 4 шаблона incoming/*.html (895 строк)

Обновлено:
- inventory/urls.py - новая URL структура
- inventory/views/incoming_document.py - обновлены redirects
- Все шаблоны с ссылками на incoming

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 17:33:00 +03:00
parent d2384394c8
commit c9ff778630
16 changed files with 30 additions and 1590 deletions

View File

@@ -93,62 +93,6 @@ class StockBatchAdmin(admin.ModelAdmin):
quantity_display.short_description = 'Количество'
# ===== INCOMING BATCH =====
@admin.register(IncomingBatch)
class IncomingBatchAdmin(admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'receipt_type_display', 'supplier_name', 'items_count', 'created_at')
list_filter = ('warehouse', 'receipt_type', 'created_at')
search_fields = ('document_number', 'supplier_name')
date_hierarchy = 'created_at'
fieldsets = (
('Партия поступления', {
'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
def items_count(self, obj):
return obj.items.count()
items_count.short_description = 'Товаров'
def receipt_type_display(self, obj):
colors = {
'supplier': '#0d6efd', # primary (синий)
'inventory': '#0dcaf0', # info (голубой)
'adjustment': '#198754', # success (зеленый)
}
color = colors.get(obj.receipt_type, '#6c757d')
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
obj.get_receipt_type_display()
)
receipt_type_display.short_description = 'Тип поступления'
# ===== INCOMING =====
@admin.register(Incoming)
class IncomingAdmin(admin.ModelAdmin):
list_display = ('product', 'batch', 'quantity', 'cost_price', 'created_at')
list_filter = ('batch__warehouse', 'created_at', 'product')
search_fields = ('product__name', 'batch__document_number')
date_hierarchy = 'created_at'
fieldsets = (
('Товар в партии', {
'fields': ('batch', 'product', 'quantity', 'cost_price', 'stock_batch')
}),
('Дата', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'stock_batch')
# ===== SALE BATCH ALLOCATION (INLINE) =====
class SaleBatchAllocationInline(admin.TabularInline):
model = SaleBatchAllocation

View File

@@ -147,212 +147,6 @@ class InventoryLineForm(forms.ModelForm):
return quantity_fact
# ============================================================================
# INCOMING FORMS - Ввод товаров (один или много) от одного поставщика
# ============================================================================
class IncomingHeaderForm(forms.Form):
"""
Форма для общей информации при приходе товаров.
Используется для ввода информации об источнике поступления (склад, номер документа, поставщик).
"""
warehouse = forms.ModelChoiceField(
queryset=Warehouse.objects.filter(is_active=True),
widget=forms.Select(attrs={'class': 'form-control'}),
label="Склад",
required=True
)
document_number = forms.CharField(
max_length=100,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}),
label="Номер документа / ПО",
required=False
)
supplier_name = forms.CharField(
max_length=200,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),
label="Наименование поставщика",
required=False
)
notes = forms.CharField(
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}),
label="Примечания",
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:
document_number = document_number.strip()
# Запретить номера, начинающиеся с "IN-" (зарезервировано для системы)
if document_number.upper().startswith('IN-'):
raise ValidationError(
'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. '
'Оставьте поле пустым для автогенерации или используйте другой формат.'
)
return document_number
class IncomingLineForm(forms.Form):
"""
Форма для одной строки товара при массовом приходе.
Используется в formset'е для динамического ввода нескольких товаров.
"""
product = forms.ModelChoiceField(
queryset=None, # Будет установлено в __init__
widget=forms.Select(attrs={'class': 'form-control'}),
label="Товар",
required=True
)
quantity = forms.DecimalField(
max_digits=10,
decimal_places=3,
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
label="Количество",
required=True
)
cost_price = forms.DecimalField(
max_digits=10,
decimal_places=2,
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
label="Цена закупки за ед.",
required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Устанавливаем queryset товаров для поля product
self.fields['product'].queryset = Product.objects.filter(
is_active=True
).order_by('name')
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity <= 0:
raise ValidationError('Количество должно быть больше нуля')
return quantity
def clean_cost_price(self):
cost_price = self.cleaned_data.get('cost_price')
if cost_price and cost_price < 0:
raise ValidationError('Цена не может быть отрицательной')
return cost_price
class IncomingForm(forms.Form):
"""
Комбинированная форма для ввода товаров (один или много).
Содержит header информацию (склад, документ, поставщик) + динамический набор товаров.
"""
warehouse = forms.ModelChoiceField(
queryset=Warehouse.objects.filter(is_active=True),
widget=forms.Select(attrs={'class': 'form-control'}),
label="Склад",
required=True
)
document_number = forms.CharField(
max_length=100,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'PO-2024-001 (опционально)'}),
label="Номер документа / ПО",
required=False
)
receipt_type = forms.CharField(
max_length=20,
widget=forms.HiddenInput(),
initial='supplier',
required=False
)
supplier_name = forms.CharField(
max_length=200,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),
label="Наименование поставщика (опционально)",
required=False
)
notes = forms.CharField(
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Дополнительная информация'}),
label="Примечания",
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:
document_number = document_number.strip()
# Запретить номера, начинающиеся с "IN-" (зарезервировано для системы)
if document_number.upper().startswith('IN-'):
raise ValidationError(
'Номера, начинающиеся с "IN-", зарезервированы для системы автогенерации. '
'Оставьте поле пустым для автогенерации или используйте другой формат.'
)
return document_number
class IncomingModelForm(forms.ModelForm):
"""
ModelForm для редактирования отдельного товара в поступлении (Incoming).
Используется в IncomingUpdateView для редактирования существующих товаров.
"""
class Meta:
model = Incoming
fields = ['product', 'quantity', 'cost_price', 'notes']
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
'cost_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Фильтруем только активные товары
self.fields['product'].queryset = Product.objects.filter(
status='active'
).order_by('name')
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity <= 0:
raise ValidationError('Количество должно быть больше нуля')
return quantity
def clean_cost_price(self):
cost_price = self.cleaned_data.get('cost_price')
if cost_price and cost_price < 0:
raise ValidationError('Цена не может быть отрицательной')
return cost_price
# ============================================================================
# TRANSFER FORMS - Перемещение товаров между складами
# ============================================================================

View File

@@ -23,9 +23,7 @@
<ul class="dropdown-menu">
<li><h6 class="dropdown-header">Управление</h6></li>
<li><a class="dropdown-item" href="{% url 'inventory:warehouse-list' %}">Склады</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:incoming-list' %}">Приходы</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:incoming-create' %}">Поступление товара</a></li>
<li><a class="dropdown-item" href="{% url 'inventory:incoming-adjustment-create' %}">Оприходование</a></li>
<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>

View File

@@ -127,7 +127,7 @@
<!-- Документы поступления -->
<div class="col-md-6 col-lg-4">
<a href="{% url 'inventory:incoming-document-list' %}" class="card shadow-sm h-100 text-decoration-none">
<a href="{% url 'inventory:incoming-list' %}" class="card shadow-sm h-100 text-decoration-none">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">

View File

@@ -1,585 +0,0 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %}
{% block inventory_title %}{% if is_adjustment %}Оприходование товара{% else %}Массовое поступление товара{% endif %}{% endblock %}
{% block breadcrumb_current %}Приходы{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">{% if is_adjustment %}Оприходование товара{% else %}Поступление товара от поставщика{% endif %}</h4>
</div>
<div class="card-body">
<!-- Ошибки общей формы -->
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>❌ Ошибка:</strong>
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" novalidate id="bulkIncomingForm">
{% csrf_token %}
<!-- ============== HEADER ИНФОРМАЦИЯ ============== -->
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{{ form.warehouse.label }} <span class="text-danger">*</span></label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="text-danger small">{{ form.warehouse.errors.0 }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{{ form.document_number.label }}</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="text-danger small">{{ form.document_number.errors.0 }}</div>
{% endif %}
<small class="text-muted d-block mt-1">
Оставьте пустым для автогенерации свободного номера (формат: IN-XXXX-XXXX). Номера, начинающиеся с IN-, зарезервированы для системы.
</small>
</div>
</div>
</div>
{% if not is_adjustment %}
<div class="mb-3">
<label class="form-label">{{ form.supplier_name.label }}</label>
{{ form.supplier_name }}
{% if form.supplier_name.errors %}
<div class="text-danger small">{{ form.supplier_name.errors.0 }}</div>
{% endif %}
</div>
{% endif %}
{{ form.receipt_type }}
<div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small">{{ form.notes.errors.0 }}</div>
{% endif %}
</div>
<hr>
<!-- ============== ТАБЛИЦА ТОВАРОВ ============== -->
<div class="mb-3">
<h5>Товары в поступлении</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="productsTable">
<thead class="table-light">
<tr>
<th style="width: 45%;">Товар</th>
<th style="width: 15%;">Кол-во (шт)</th>
<th style="width: 15%;">Цена закупки</th>
<th style="width: 15%;">Сумма</th>
<th style="width: 10%;">Действие</th>
</tr>
</thead>
<tbody id="productsBody">
<!-- Строки будут добавлены через JavaScript -->
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addRowBtn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
</div>
<!-- ============== ИТОГО ============== -->
<div class="row mb-3">
<div class="col-md-6"></div>
<div class="col-md-6">
<div class="card bg-light">
<div class="card-body">
<div class="row mb-2">
<div class="col-6"><strong>Кол-во позиций:</strong></div>
<div class="col-6 text-end"><span id="totalItems">0</span></div>
</div>
<div class="row mb-2">
<div class="col-6"><strong>Общее количество:</strong></div>
<div class="col-6 text-end"><span id="totalQuantity">0</span> шт</div>
</div>
<div class="row">
<div class="col-6"><strong>Сумма поступления:</strong></div>
<div class="col-6 text-end text-primary"><strong><span id="totalSum">0.00</span> руб</strong></div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden input для JSON данных товаров -->
<input type="hidden" id="productsJson" name="products_json" value="[]">
<!-- ============== КНОПКИ ============== -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-check-circle"></i> {% if is_adjustment %}Создать оприходование{% else %}Создать поступление{% endif %}
</button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
.table-responsive {
border-radius: 4px;
border: 1px solid #dee2e6;
}
.btn-remove-row {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
input[readonly] {
background-color: #e9ecef;
}
.row-error {
background-color: #fff5f5;
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.is-invalid {
border-color: #dc3545 !important;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('bulkIncomingForm');
const productsBody = document.getElementById('productsBody');
const addRowBtn = document.getElementById('addRowBtn');
const productsJsonInput = document.getElementById('productsJson');
const submitBtn = document.getElementById('submitBtn');
let rowCounter = 0;
// Добавление новой строки товара
addRowBtn.addEventListener('click', function(e) {
e.preventDefault();
addProductRow();
});
function addProductRow() {
rowCounter++;
const rowId = `row-${rowCounter}`;
const row = document.createElement('tr');
row.id = rowId;
row.innerHTML = `
<td>
<select class="form-control form-control-sm product-select" data-row-id="${rowId}">
<option value="">Начните вводить название товара...</option>
</select>
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="number" class="form-control form-control-sm quantity-input"
data-row-id="${rowId}" step="0.001" placeholder="0" min="0">
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="number" class="form-control form-control-sm price-input"
data-row-id="${rowId}" step="0.01" placeholder="0.00" min="0">
<div class="error-message" style="display:none;"></div>
</td>
<td>
<input type="text" class="form-control form-control-sm sum-display"
data-row-id="${rowId}" readonly style="text-align:right;">
</td>
<td>
<button type="button" class="btn btn-sm btn-danger btn-remove-row" data-row-id="${rowId}">
<i class="bi bi-trash"></i>
</button>
</td>
`;
productsBody.appendChild(row);
// Инициализируем Select2 для нового селекта товара
const productSelect = row.querySelector('.product-select');
$(productSelect).select2({
theme: 'bootstrap-5',
placeholder: 'Начните вводить название товара...',
allowClear: true,
width: '100%',
language: 'ru',
minimumInputLength: 0,
ajax: {
url: '/products/api/search-products-variants/',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term || '',
type: 'product',
page: params.page || 1
};
},
processResults: function (data) {
// Преобразуем результаты: извлекаем числовые ID из 'product_123'
const processGroup = function(group) {
if (group.children) {
// Это группа - обрабатываем детей
return {
text: group.text,
children: group.children.map(item => ({
id: item.id.replace('product_', ''), // Убираем префикс
text: item.text,
sku: item.sku,
price: item.price,
actual_price: item.actual_price
}))
};
} else {
// Это отдельный элемент
return {
id: group.id.replace('product_', ''),
text: group.text,
sku: group.sku,
price: group.price,
actual_price: group.actual_price
};
}
};
const processedResults = data.results.map(processGroup);
return {
results: processedResults,
pagination: {
more: data.pagination.more
}
};
},
cache: true
}
});
// Добавляем event listeners для новой строки
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const removeBtn = row.querySelector('.btn-remove-row');
$(productSelect).on('change', function() {
productSelect.classList.remove('is-invalid');
updateTotals();
});
quantityInput.addEventListener('input', function() {
quantityInput.classList.remove('is-invalid');
updateTotals();
});
priceInput.addEventListener('input', function() {
priceInput.classList.remove('is-invalid');
updateTotals();
});
removeBtn.addEventListener('click', function(e) {
e.preventDefault();
$(productSelect).select2('destroy');
row.remove();
updateTotals();
});
updateTotals();
}
function updateTotals() {
let totalItems = 0;
let totalQuantity = 0;
let totalSum = 0;
const productsData = [];
productsBody.querySelectorAll('tr').forEach(row => {
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const sumDisplay = row.querySelector('.sum-display');
const productId = productSelect.value;
const quantity = parseFloat(quantityInput.value) || 0;
const priceValue = priceInput.value.trim();
const price = parseFloat(priceValue) || 0;
const sum = quantity * price;
// Обновляем дисплей суммы
sumDisplay.value = sum.toFixed(2);
// Только считаем если данные ПОЛНОСТЬЮ заполнены (включая цену!)
if (productId && quantity > 0 && priceValue !== '' && price >= 0) {
totalItems++;
totalQuantity += quantity;
totalSum += sum;
productsData.push({
product_id: parseInt(productId),
quantity: quantity,
cost_price: price
});
}
});
// Обновляем итоги
document.getElementById('totalItems').textContent = totalItems;
document.getElementById('totalQuantity').textContent = totalQuantity.toFixed(3);
document.getElementById('totalSum').textContent = totalSum.toFixed(2);
// Обновляем JSON данные
productsJsonInput.value = JSON.stringify(productsData);
// Отключаем кнопку отправки если нет товаров
submitBtn.disabled = totalItems === 0;
}
// Добавляем первую пустую строку
addProductRow();
// Восстанавливаем товары из JSON если была ошибка (сохранение данных при ошибке)
const savedProductsJson = '{{ products_json|escapejs }}';
if (savedProductsJson && savedProductsJson.trim() !== '[]' && savedProductsJson.trim() !== '') {
try {
const savedProducts = JSON.parse(savedProductsJson);
if (savedProducts && savedProducts.length > 0) {
// Удаляем пустую первую строку
productsBody.innerHTML = '';
rowCounter = 0;
// Добавляем восстановленные товары
savedProducts.forEach(item => {
addProductRow();
const lastRow = productsBody.querySelector('tr:last-child');
const lastSelect = lastRow.querySelector('.product-select');
// Создаём option и устанавливаем его в Select2
const newOption = new Option(item.product_name || `Товар #${item.product_id}`, item.product_id, true, true);
$(lastSelect).append(newOption).trigger('change');
lastRow.querySelector('.quantity-input').value = item.quantity;
lastRow.querySelector('.price-input').value = item.cost_price;
});
// Обновляем итоги
updateTotals();
// Очищаем поле номера документа для автогенерации
const documentNumberInput = document.querySelector('[name="document_number"]');
if (documentNumberInput) {
documentNumberInput.value = '';
}
}
} catch (e) {
console.error('Ошибка восстановления товаров:', e);
}
}
// Получаем элемент поля номера документа
const documentNumberInput = document.querySelector('[name="document_number"]');
// Валидация номера документа (запретить номера, начинающиеся с "IN-" только для заполненного поля)
documentNumberInput.addEventListener('change', function() {
const value = this.value.trim().toUpperCase();
const container = this.closest('.mb-3');
let errorDiv = container.querySelector('.document-number-error');
// Проверяем IN-* ТОЛЬКО если поле НЕ пусто
if (value && value.startsWith('IN-')) {
// Показать ошибку
this.classList.add('is-invalid');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'document-number-error text-danger small mt-2';
container.appendChild(errorDiv);
}
errorDiv.textContent = 'Номера, начинающиеся с "IN-", зарезервированы для системы. Если хотите автогенерацию, оставьте поле пустым.';
} else {
// Очистить ошибку (пусто или другой формат - это ОК)
this.classList.remove('is-invalid');
if (errorDiv) {
errorDiv.remove();
}
}
});
// Валидация перед отправкой
form.addEventListener('submit', function(e) {
// Проверка номера документа - запретить IN-* только если поле ЗАПОЛНЕНО
const docNumberValue = documentNumberInput.value.trim().toUpperCase();
const docNumberContainer = documentNumberInput.closest('.mb-3');
if (docNumberValue && docNumberValue.startsWith('IN-')) {
e.preventDefault();
documentNumberInput.classList.add('is-invalid');
let errorDiv = docNumberContainer.querySelector('.document-number-error');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'document-number-error text-danger small mt-2';
docNumberContainer.appendChild(errorDiv);
}
errorDiv.textContent = 'Номера, начинающиеся с "IN-", зарезервированы для системы. Оставьте пустым для автогенерации.';
alert('Номера, начинающиеся с "IN-", зарезервированы для системы. Оставьте пустым для автогенерации.');
documentNumberInput.focus();
return false;
}
// Проверка склада
const warehouseSelect = document.querySelector('[name="warehouse"]');
const warehouseContainer = warehouseSelect.closest('.mb-3');
if (!warehouseSelect.value) {
e.preventDefault();
// Добавляем класс ошибки если его нет
if (!warehouseSelect.classList.contains('is-invalid')) {
warehouseSelect.classList.add('is-invalid');
warehouseContainer.classList.add('has-validation');
}
// Создаём или обновляем сообщение об ошибке
let errorDiv = warehouseContainer.querySelector('.warehouse-error');
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'warehouse-error text-danger small mt-2';
warehouseContainer.appendChild(errorDiv);
}
errorDiv.textContent = 'Пожалуйста, выберите склад перед отправкой.';
alert('Пожалуйста, выберите склад перед отправкой.');
warehouseSelect.focus();
return false;
} else {
// Очищаем ошибку если склад выбран
warehouseSelect.classList.remove('is-invalid');
const errorDiv = warehouseContainer.querySelector('.warehouse-error');
if (errorDiv) {
errorDiv.remove();
}
}
// СНАЧАЛА проверяем корректность заполнения товаров
let hasErrors = false;
let hasAnyProduct = false;
productsBody.querySelectorAll('tr').forEach(row => {
const productSelect = row.querySelector('.product-select');
const quantityInput = row.querySelector('.quantity-input');
const priceInput = row.querySelector('.price-input');
const productError = row.querySelector('td:nth-child(1) .error-message');
const quantityError = row.querySelector('td:nth-child(2) .error-message');
const priceError = row.querySelector('td:nth-child(3) .error-message');
let rowHasError = false;
// Проверка товара
if (!productSelect.value) {
productError.textContent = 'Выберите товар';
productError.style.display = 'block';
productSelect.classList.add('is-invalid');
hasErrors = true;
rowHasError = true;
} else {
productError.style.display = 'none';
productSelect.classList.remove('is-invalid');
hasAnyProduct = true;
}
// Проверка количества
const quantity = parseFloat(quantityInput.value) || 0;
if (quantity <= 0) {
quantityError.textContent = 'Количество должно быть > 0';
quantityError.style.display = 'block';
quantityInput.classList.add('is-invalid');
hasErrors = true;
rowHasError = true;
} else {
quantityError.style.display = 'none';
quantityInput.classList.remove('is-invalid');
}
// Проверка цены - ОБЯЗАТЕЛЬНОЕ ПОЛЕ!
const priceValue = priceInput.value.trim();
const price = parseFloat(priceValue);
if (priceValue === '' || isNaN(price)) {
priceError.textContent = 'Укажите цену закупки';
priceError.style.display = 'block';
priceInput.classList.add('is-invalid');
hasErrors = true;
rowHasError = true;
} else if (price < 0) {
priceError.textContent = 'Цена не может быть отрицательной';
priceError.style.display = 'block';
priceInput.classList.add('is-invalid');
hasErrors = true;
rowHasError = true;
} else {
priceError.style.display = 'none';
priceInput.classList.remove('is-invalid');
}
if (rowHasError) {
row.classList.add('row-error');
} else {
row.classList.remove('row-error');
}
});
// Проверка что есть хотя бы один товар
if (!hasAnyProduct) {
e.preventDefault();
alert('Пожалуйста, добавьте хотя бы один товар.');
return false;
}
// Если есть ошибки валидации - показываем
if (hasErrors) {
e.preventDefault();
alert('⚠️ Пожалуйста, исправьте ошибки в форме (см. подсвеченные поля).');
// Прокручиваем к первой ошибке
const firstError = productsBody.querySelector('.is-invalid');
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstError.focus();
}
return false;
}
// Проверяем финальные данные
const productsData = JSON.parse(productsJsonInput.value);
if (productsData.length === 0) {
e.preventDefault();
alert('⚠️ Необходимо корректно заполнить все поля товаров.');
return false;
}
});
});
</script>
{% endblock %}

View File

@@ -1,50 +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 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>
<p class="text-muted">
Это действие удалит запись о приходе товара и может повлиять на остатки на складе.
</p>
<div class="alert alert-info">
<h5>Информация о приходе:</h5>
<ul class="mb-0">
<li><strong>Товар:</strong> {{ incoming.product.name }}</li>
<li><strong>Склад:</strong> {{ incoming.warehouse.name }}</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 %}
</ul>
</div>
<form method="post" class="mt-4">
{% 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:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Вернуться
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,134 +0,0 @@
{% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %}
{% block inventory_title %}
{% if form.instance.pk %}
Редактирование прихода товара
{% else %}
Новый приход товара
{% endif %}
{% endblock %}
{% block breadcrumb_current %}Приходы{% endblock %}
{% block inventory_content %}
<div class="card">
<div class="card-header">
<h4 class="mb-0">
{% if form.instance.pk %}
Редактирование прихода
{% else %}
Регистрация нового поступления
{% endif %}
</h4>
</div>
<div class="card-body">
{% if form.instance.pk and not form.instance.can_edit %}
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Этот приход товара уже обработан (для него создана складская партия).
Редактирование недоступно, так как партия может быть использована в продажах.
</div>
{% endif %}
<form method="post" class="form">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.product.id_for_label }}" class="form-label">
{{ form.product.label }} <span class="text-danger">*</span>
</label>
{{ form.product }}
{% if form.product.errors %}
<div class="invalid-feedback d-block">
{% for error in form.product.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.warehouse.id_for_label }}" class="form-label">
{{ form.warehouse.label }} <span class="text-danger">*</span>
</label>
{{ form.warehouse }}
{% if form.warehouse.errors %}
<div class="invalid-feedback d-block">
{% for error in form.warehouse.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.quantity.id_for_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">
{% for error in form.quantity.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.cost_price.id_for_label }}" class="form-label">
{{ form.cost_price.label }} <span class="text-danger">*</span>
</label>
{{ form.cost_price }}
{% if form.cost_price.errors %}
<div class="invalid-feedback d-block">
{% for error in form.cost_price.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.document_number.id_for_label }}" class="form-label">
{{ form.document_number.label }}
</label>
{{ form.document_number }}
{% if form.document_number.errors %}
<div class="invalid-feedback d-block">
{% for error in form.document_number.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
{{ form.notes.label }}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="invalid-feedback d-block">
{% for error in form.notes.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i>
{% if form.instance.pk %}
Сохранить
{% else %}
Создать
{% endif %}
</button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отменить
</a>
</div>
</form>
</div>
</div>
<style>
select, textarea, input {
width: 100%;
}
</style>
{% endblock %}

View File

@@ -1,126 +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:incoming-create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Новый приход
</a>
</div>
<div class="card-body">
{% if incomings %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Склад</th>
<th>Тип</th>
<th>Количество</th>
<th>Цена закупки</th>
<th>Номер документа</th>
<th>Партия</th>
<th>Дата</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
{% for incoming in incomings %}
<tr>
<td><strong>{{ incoming.product.name }}</strong></td>
<td>{{ incoming.batch.warehouse.name }}</td>
<td>
<span class="badge bg-{% if incoming.batch.receipt_type == 'supplier' %}primary{% elif incoming.batch.receipt_type == 'inventory' %}info{% else %}success{% endif %}">
{{ incoming.batch.get_receipt_type_display }}
</span>
</td>
<td>{{ incoming.quantity|smart_quantity }} шт</td>
<td>{{ incoming.cost_price }} руб.</td>
<td>
{% if incoming.batch.document_number %}
<code>{{ incoming.batch.document_number }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if incoming.stock_batch %}
<a href="{% url 'inventory:batch-detail' incoming.stock_batch.pk %}" title="Перейти к партии на складе">
<strong>#{{ incoming.stock_batch.pk }}</strong>
</a>
{% else %}
<span class="badge bg-warning">Не назначена</span>
{% endif %}
</td>
<td>{{ incoming.created_at|date:"d.m.Y H:i" }}</td>
<td class="text-end">
{% if incoming.can_edit %}
<a href="{% url 'inventory:incoming-update' incoming.pk %}" class="btn btn-sm btn-outline-primary" title="Редактировать приход">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<span class="btn btn-sm btn-outline-secondary disabled" title="Редактирование недоступно: приход уже обработан">
<i class="bi bi-pencil"></i>
</span>
{% endif %}
<a href="{% url 'inventory:incoming-delete' incoming.pk %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Приходов не найдено.
<a href="{% url 'inventory:incoming-create' %}" class="alert-link">Зарегистрировать новый приход</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -12,7 +12,7 @@
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-document-list' %}">Документы поступления</a></li>
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-list' %}">Документы поступления</a></li>
<li class="breadcrumb-item active">{{ document.document_number }}</li>
</ol>
</nav>
@@ -45,13 +45,13 @@
</h5>
{% if document.can_edit %}
<div class="btn-group">
<form method="post" action="{% url 'inventory:incoming-document-confirm' document.pk %}" class="d-inline">
<form method="post" action="{% url 'inventory:incoming-confirm' document.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-success btn-sm" {% if not document.can_confirm %}disabled{% endif %}>
<i class="bi bi-check-lg me-1"></i>Провести
</button>
</form>
<form method="post" action="{% url 'inventory:incoming-document-cancel' document.pk %}" class="d-inline ms-2">
<form method="post" action="{% url 'inventory:incoming-cancel' document.pk %}" class="d-inline ms-2">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить документ?')">
<i class="bi bi-x-lg me-1"></i>Отменить
@@ -118,7 +118,7 @@
<div class="card-body">
<!-- Компонент поиска товаров -->
<div class="mb-3">
{% include 'products/components/product_search_picker.html' with container_id='incoming-document-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
{% include 'products/components/product_search_picker.html' with container_id='incoming-picker' title='Найти товар для поступления' warehouse_id=document.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
</div>
<!-- Информация о выбранном товаре -->
@@ -136,7 +136,7 @@
</div>
<!-- Форма добавления позиции -->
<form method="post" action="{% url 'inventory:incoming-document-add-item' document.pk %}" id="add-item-form">
<form method="post" action="{% url 'inventory:incoming-add-item' document.pk %}" id="add-item-form">
{% csrf_token %}
<div class="row g-3">
@@ -257,7 +257,7 @@
</button>
</div>
<form id="delete-form-{{ item.id }}" method="post"
action="{% url 'inventory:incoming-document-remove-item' document.pk item.pk %}"
action="{% url 'inventory:incoming-remove-item' document.pk item.pk %}"
style="display: none;">
{% csrf_token %}
</form>
@@ -308,7 +308,7 @@ document.addEventListener('DOMContentLoaded', function() {
const clearSelectedBtn = document.getElementById('clear-selected-product');
// Инициализация компонента поиска товаров
const picker = ProductSearchPicker.init('#incoming-document-picker', {
const picker = ProductSearchPicker.init('#incoming-picker', {
onAddSelected: function(product, instance) {
if (product) {
selectProduct(product);

View File

@@ -7,7 +7,7 @@
<!-- Breadcrumbs -->
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-document-list' %}">Документы поступления</a></li>
<li class="breadcrumb-item"><a href="{% url 'inventory:incoming-list' %}">Документы поступления</a></li>
<li class="breadcrumb-item active">Создать</li>
</ol>
</nav>
@@ -66,7 +66,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Создать
</button>
<a href="{% url 'inventory:incoming-document-list' %}" class="btn btn-outline-secondary">
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
</div>

View File

@@ -9,7 +9,7 @@
<h4 class="mb-0">
<i class="bi bi-file-earmark-plus me-2"></i>Документы поступления
</h4>
<a href="{% url 'inventory:incoming-document-create' %}" class="btn btn-primary">
<a href="{% url 'inventory:incoming-create' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Создать документ
</a>
</div>
@@ -46,7 +46,7 @@
{% for doc in documents %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'inventory:incoming-document-detail' doc.pk %}" class="fw-semibold text-decoration-none">
<a href="{% url 'inventory:incoming-detail' doc.pk %}" class="fw-semibold text-decoration-none">
{{ doc.document_number }}
</a>
</td>
@@ -70,7 +70,7 @@
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %}
</td>
<td class="px-3 py-2 text-end">
<a href="{% url 'inventory:incoming-document-detail' doc.pk %}" class="btn btn-sm btn-outline-primary">
<a href="{% url 'inventory:incoming-detail' doc.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>

View File

@@ -84,7 +84,7 @@
{% if incoming_document %}
<p class="mb-1">
<strong>Документ оприходования:</strong>
<a href="{% url 'inventory:incoming-document-detail' incoming_document.pk %}" target="_blank">
<a href="{% url 'inventory:incoming-detail' incoming_document.pk %}" target="_blank">
{{ incoming_document.document_number }}
</a>
<span class="badge {% if incoming_document.status == 'confirmed' %}bg-success{% else %}bg-warning{% endif %} ms-2">

View File

@@ -3,8 +3,6 @@ from django.urls import path
from .views import (
# Warehouse
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
# Incoming
IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView,
# IncomingBatch
IncomingBatchListView, IncomingBatchDetailView,
# Sale
@@ -66,13 +64,6 @@ urlpatterns = [
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'),
path('incoming/create/', IncomingCreateView.as_view(), name='incoming-create'),
path('incoming/adjustment/create/', IncomingAdjustmentCreateView.as_view(), name='incoming-adjustment-create'),
path('incoming/<int:pk>/edit/', IncomingUpdateView.as_view(), name='incoming-update'),
path('incoming/<int:pk>/delete/', IncomingDeleteView.as_view(), name='incoming-delete'),
# ==================== INCOMING BATCH ====================
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
path('incoming-batches/<int:pk>/', IncomingBatchDetailView.as_view(), name='incoming-batch-detail'),
@@ -112,15 +103,15 @@ urlpatterns = [
path('writeoff-documents/<int:pk>/confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'),
path('writeoff-documents/<int:pk>/cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'),
# ==================== INCOMING DOCUMENT (документы поступления) ====================
path('incoming-documents/', IncomingDocumentListView.as_view(), name='incoming-document-list'),
path('incoming-documents/create/', IncomingDocumentCreateView.as_view(), name='incoming-document-create'),
path('incoming-documents/<int:pk>/', IncomingDocumentDetailView.as_view(), name='incoming-document-detail'),
path('incoming-documents/<int:pk>/add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-document-add-item'),
path('incoming-documents/<int:pk>/update-item/<int:item_pk>/', IncomingDocumentUpdateItemView.as_view(), name='incoming-document-update-item'),
path('incoming-documents/<int:pk>/remove-item/<int:item_pk>/', IncomingDocumentRemoveItemView.as_view(), name='incoming-document-remove-item'),
path('incoming-documents/<int:pk>/confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-document-confirm'),
path('incoming-documents/<int:pk>/cancel/', IncomingDocumentCancelView.as_view(), name='incoming-document-cancel'),
# ==================== INCOMING (документы поступления) ====================
path('incoming/', IncomingDocumentListView.as_view(), name='incoming-list'),
path('incoming/create/', IncomingDocumentCreateView.as_view(), name='incoming-create'),
path('incoming/<int:pk>/', IncomingDocumentDetailView.as_view(), name='incoming-detail'),
path('incoming/<int:pk>/add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-add-item'),
path('incoming/<int:pk>/update-item/<int:item_pk>/', IncomingDocumentUpdateItemView.as_view(), name='incoming-update-item'),
path('incoming/<int:pk>/remove-item/<int:item_pk>/', IncomingDocumentRemoveItemView.as_view(), name='incoming-remove-item'),
path('incoming/<int:pk>/confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-confirm'),
path('incoming/<int:pk>/cancel/', IncomingDocumentCancelView.as_view(), name='incoming-cancel'),
# ==================== TRANSFER ====================
path('transfers/', TransferListView.as_view(), name='transfer-list'),

View File

@@ -19,7 +19,6 @@ from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
from .incoming import IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
from .inventory_ops import (
@@ -58,8 +57,6 @@ __all__ = [
'inventory_home',
# Warehouse
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
# Incoming
'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
# IncomingBatch
'IncomingBatchListView', 'IncomingBatchDetailView',
# Sale

View File

@@ -1,389 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from django.shortcuts import render, redirect
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, Http404
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from django.db import IntegrityError, transaction
from ..models import Incoming, IncomingBatch, Warehouse
from ..forms import IncomingForm, IncomingLineForm, IncomingModelForm
from inventory.utils import generate_incoming_document_number
from products.models import Product
file_logger = logging.getLogger('incoming_sequence_file')
class IncomingListView(LoginRequiredMixin, ListView):
"""
Список всех приходов товара (истории поступлений)
"""
model = Incoming
template_name = 'inventory/incoming/incoming_list.html'
context_object_name = 'incomings'
paginate_by = 20
def get_queryset(self):
queryset = Incoming.objects.select_related('product', 'batch', 'batch__warehouse').order_by('-created_at')
# Фильтры (если переданы)
product_id = self.request.GET.get('product')
warehouse_id = self.request.GET.get('warehouse')
if product_id:
queryset = queryset.filter(product_id=product_id)
if warehouse_id:
queryset = queryset.filter(batch__warehouse_id=warehouse_id)
return queryset
class IncomingUpdateView(LoginRequiredMixin, UpdateView):
"""
Редактирование поступления (только если ещё не обработано).
Обработанные приходы редактировать нельзя.
"""
model = Incoming
form_class = IncomingModelForm
template_name = 'inventory/incoming/incoming_form.html'
success_url = reverse_lazy('inventory:incoming-list')
def dispatch(self, request, *args, **kwargs):
"""Проверяем, можно ли редактировать приход"""
obj = self.get_object()
if not obj.can_edit:
messages.error(
request,
f'Нельзя редактировать приход товара "{obj.product.name}", '
f'так как для него уже создана складская партия. '
f'Редактирование возможно только до обработки прихода.'
)
return redirect('inventory:incoming-list')
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
messages.success(self.request, f'Приход товара обновлён.')
return super().form_valid(form)
class IncomingDeleteView(LoginRequiredMixin, DeleteView):
"""
Отмена/удаление поступления товара.
"""
model = Incoming
template_name = 'inventory/incoming/incoming_confirm_delete.html'
success_url = reverse_lazy('inventory:incoming-list')
def form_valid(self, form):
incoming = self.get_object()
messages.success(
self.request,
f'Приход товара "{incoming.product.name}" отменён.'
)
return super().form_valid(form)
class IncomingCreateView(LoginRequiredMixin, View):
"""
Создание поступлений товара на склад.
Позволяет добавить один или несколько товаров в одной форме
с одинаковым номером документа и складом.
По умолчанию показывается одна пустая строка товара,
но пользователь может добавить неограниченное количество товаров.
"""
template_name = 'inventory/incoming/incoming_bulk_form.html'
def get(self, request):
"""Отображение формы ввода товаров."""
form = IncomingForm()
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
# Генерируем номер документа автоматически
generated_document_number = generate_incoming_document_number()
context = {
'form': form,
'products': products,
'generated_document_number': generated_document_number,
}
return render(request, self.template_name, context)
def post(self, request):
"""Обработка формы ввода товаров."""
form = IncomingForm(request.POST)
if not form.is_valid():
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'errors': form.errors,
}
return render(request, self.template_name, context)
# Получаем данные header
warehouse = form.cleaned_data['warehouse']
# Оставляем пустой document_number как есть - модель будет генерировать при сохранении
document_number = form.cleaned_data.get('document_number', '').strip() or None
receipt_type = form.cleaned_data.get('receipt_type', 'supplier')
supplier_name = form.cleaned_data.get('supplier_name', '')
header_notes = form.cleaned_data.get('notes', '')
# Получаем данные товаров из POST
products_data = self._parse_products_from_post(request.POST)
if not products_data:
messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.')
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
}
return render(request, self.template_name, context)
# Генерируем номер партии один раз (если не указан)
if not document_number:
document_number = generate_incoming_document_number()
file_logger.info(f"--- POST started | batch_doc_number={document_number} | items_count={len(products_data)}")
try:
# Используем транзакцию для атомарности: либо все товары, либо ничего
with transaction.atomic():
# 1. Создаем партию (содержит номер документа и метаданные)
batch = IncomingBatch.objects.create(
warehouse=warehouse,
document_number=document_number,
receipt_type=receipt_type,
supplier_name=supplier_name if receipt_type == 'supplier' else '',
notes=header_notes
)
file_logger.info(f" ✓ Created batch: {document_number}")
# 2. Создаем товары в этой партии
created_count = 0
for product_data in products_data:
incoming = Incoming.objects.create(
batch=batch,
product_id=product_data['product_id'],
quantity=product_data['quantity'],
cost_price=product_data['cost_price'],
)
created_count += 1
file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)")
file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items")
messages.success(
request,
f'✓ Успешно создана партия "{document_number}" с {created_count} товарами.'
)
return redirect('inventory:incoming-list')
except IntegrityError as e:
# Ошибка дублирования номера (обычно при вводе вручную существующего номера)
file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}")
if 'document_number' in str(e):
error_msg = (
f'❌ Номер документа "{document_number}" уже существует в системе. '
f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. '
f'Данные, которые вы вводили, сохранены ниже.'
)
messages.error(request, error_msg)
else:
messages.error(request, f'Ошибка при создании партии: {str(e)}')
# Восстанавливаем данные на форме
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'products_json': request.POST.get('products_json', '[]'),
}
return render(request, self.template_name, context)
except Exception as e:
messages.error(
request,
f'❌ Ошибка при создании приходов: {str(e)}'
)
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'products_json': request.POST.get('products_json', '[]'), # Сохраняем товары
}
return render(request, self.template_name, context)
@staticmethod
def _parse_products_from_post(post_data):
"""
Парсит данные товаров из POST данных.
Ожидается formato:
product_ids: [1, 2, 3]
quantities: [100, 50, 30]
cost_prices: [50, 30, 20]
"""
products_data = []
# Получаем JSON данные из hidden input (если используется)
import json
products_json = post_data.get('products_json', '[]')
try:
products_list = json.loads(products_json)
for item in products_list:
if item.get('product_id') and item.get('quantity') and item.get('cost_price'):
products_data.append({
'product_id': int(item['product_id']),
'quantity': float(item['quantity']),
'cost_price': float(item['cost_price']),
})
except (json.JSONDecodeError, ValueError):
pass
return products_data
class IncomingAdjustmentCreateView(LoginRequiredMixin, View):
"""
Создание оприходования товара на склад (без инвентаризации).
Аналогично IncomingCreateView, но с типом 'adjustment' и без поля поставщика.
"""
template_name = 'inventory/incoming/incoming_bulk_form.html'
def get(self, request):
"""Отображение формы ввода товаров."""
form = IncomingForm(initial={'receipt_type': 'adjustment'})
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
# Генерируем номер документа автоматически
generated_document_number = generate_incoming_document_number()
context = {
'form': form,
'products': products,
'generated_document_number': generated_document_number,
'is_adjustment': True, # Флаг для шаблона, чтобы скрыть supplier_name
}
return render(request, self.template_name, context)
def post(self, request):
"""Обработка формы ввода товаров."""
# Устанавливаем receipt_type в 'adjustment'
post_data = request.POST.copy()
post_data['receipt_type'] = 'adjustment'
form = IncomingForm(post_data)
if not form.is_valid():
# Django-tenants автоматически фильтрует по текущей схеме
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'errors': form.errors,
'is_adjustment': True,
}
return render(request, self.template_name, context)
# Получаем данные header
warehouse = form.cleaned_data['warehouse']
document_number = form.cleaned_data.get('document_number', '').strip() or None
receipt_type = 'adjustment' # Всегда adjustment для этого view
header_notes = form.cleaned_data.get('notes', '')
# Получаем данные товаров из POST
products_data = IncomingCreateView._parse_products_from_post(request.POST)
if not products_data:
messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.')
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'is_adjustment': True,
}
return render(request, self.template_name, context)
# Генерируем номер партии один раз (если не указан)
if not document_number:
document_number = generate_incoming_document_number()
file_logger.info(f"--- POST started (adjustment) | batch_doc_number={document_number} | items_count={len(products_data)}")
try:
# Используем транзакцию для атомарности: либо все товары, либо ничего
with transaction.atomic():
# 1. Создаем партию (содержит номер документа и метаданные)
batch = IncomingBatch.objects.create(
warehouse=warehouse,
document_number=document_number,
receipt_type=receipt_type,
supplier_name='', # Не заполняем для adjustment
notes=header_notes
)
file_logger.info(f" ✓ Created batch: {document_number}")
# 2. Создаем товары в этой партии
created_count = 0
for product_data in products_data:
incoming = Incoming.objects.create(
batch=batch,
product_id=product_data['product_id'],
quantity=product_data['quantity'],
cost_price=product_data['cost_price'],
)
created_count += 1
file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)")
file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items")
messages.success(
request,
f'✓ Успешно создано оприходование "{document_number}" с {created_count} товарами.'
)
return redirect('inventory:incoming-list')
except IntegrityError as e:
file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}")
if 'document_number' in str(e):
error_msg = (
f'❌ Номер документа "{document_number}" уже существует в системе. '
f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. '
f'Данные, которые вы вводили, сохранены ниже.'
)
messages.error(request, error_msg)
else:
messages.error(request, f'Ошибка при создании оприходования: {str(e)}')
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'products_json': request.POST.get('products_json', '[]'),
'is_adjustment': True,
}
return render(request, self.template_name, context)
except Exception as e:
messages.error(
request,
f'❌ Ошибка при создании оприходования: {str(e)}'
)
products = Product.objects.filter(status='active').order_by('name')
context = {
'form': form,
'products': products,
'products_json': request.POST.get('products_json', '[]'),
'is_adjustment': True,
}
return render(request, self.template_name, context)

View File

@@ -45,7 +45,7 @@ class IncomingDocumentCreateView(LoginRequiredMixin, CreateView):
created_by=self.request.user
)
messages.success(self.request, f'Документ {document.document_number} создан')
return redirect('inventory:incoming-document-detail', pk=document.pk)
return redirect('inventory:incoming-detail', pk=document.pk)
class IncomingDocumentDetailView(LoginRequiredMixin, DetailView):
@@ -110,7 +110,7 @@ class IncomingDocumentAddItemView(LoginRequiredMixin, View):
for error in errors:
messages.error(request, f'{field}: {error}')
return redirect('inventory:incoming-document-detail', pk=pk)
return redirect('inventory:incoming-detail', pk=pk)
class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
@@ -146,7 +146,7 @@ class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
return redirect('inventory:incoming-document-detail', pk=pk)
return redirect('inventory:incoming-detail', pk=pk)
class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
@@ -174,7 +174,7 @@ class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
return JsonResponse({'success': False, 'error': str(e)}, status=400)
messages.error(request, str(e))
return redirect('inventory:incoming-document-detail', pk=pk)
return redirect('inventory:incoming-detail', pk=pk)
class IncomingDocumentConfirmView(LoginRequiredMixin, View):
@@ -197,7 +197,7 @@ class IncomingDocumentConfirmView(LoginRequiredMixin, View):
except ValidationError as e:
messages.error(request, str(e))
return redirect('inventory:incoming-document-detail', pk=pk)
return redirect('inventory:incoming-detail', pk=pk)
class IncomingDocumentCancelView(LoginRequiredMixin, View):
@@ -213,5 +213,5 @@ class IncomingDocumentCancelView(LoginRequiredMixin, View):
except ValidationError as e:
messages.error(request, str(e))
return redirect('inventory:incoming-document-list')
return redirect('inventory:incoming-list')