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:
@@ -93,62 +93,6 @@ class StockBatchAdmin(admin.ModelAdmin):
|
|||||||
quantity_display.short_description = 'Количество'
|
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) =====
|
# ===== SALE BATCH ALLOCATION (INLINE) =====
|
||||||
class SaleBatchAllocationInline(admin.TabularInline):
|
class SaleBatchAllocationInline(admin.TabularInline):
|
||||||
model = SaleBatchAllocation
|
model = SaleBatchAllocation
|
||||||
|
|||||||
@@ -147,212 +147,6 @@ class InventoryLineForm(forms.ModelForm):
|
|||||||
return quantity_fact
|
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 - Перемещение товаров между складами
|
# TRANSFER FORMS - Перемещение товаров между складами
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -23,9 +23,7 @@
|
|||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><h6 class="dropdown-header">Управление</h6></li>
|
<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: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-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:sale-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: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-list' %}">Списания</a></li>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
<!-- Документы поступления -->
|
<!-- Документы поступления -->
|
||||||
<div class="col-md-6 col-lg-4">
|
<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="card-body p-3">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb breadcrumb-sm mb-0">
|
<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>
|
<li class="breadcrumb-item active">{{ document.document_number }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
</h5>
|
</h5>
|
||||||
{% if document.can_edit %}
|
{% if document.can_edit %}
|
||||||
<div class="btn-group">
|
<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 %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-success btn-sm" {% if not document.can_confirm %}disabled{% endif %}>
|
<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>Провести
|
<i class="bi bi-check-lg me-1"></i>Провести
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить документ?')">
|
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Отменить документ?')">
|
||||||
<i class="bi bi-x-lg me-1"></i>Отменить
|
<i class="bi bi-x-lg me-1"></i>Отменить
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- Компонент поиска товаров -->
|
<!-- Компонент поиска товаров -->
|
||||||
<div class="mb-3">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Информация о выбранном товаре -->
|
<!-- Информация о выбранном товаре -->
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
</div>
|
</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 %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="delete-form-{{ item.id }}" method="post"
|
<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;">
|
style="display: none;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
</form>
|
</form>
|
||||||
@@ -308,7 +308,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const clearSelectedBtn = document.getElementById('clear-selected-product');
|
const clearSelectedBtn = document.getElementById('clear-selected-product');
|
||||||
|
|
||||||
// Инициализация компонента поиска товаров
|
// Инициализация компонента поиска товаров
|
||||||
const picker = ProductSearchPicker.init('#incoming-document-picker', {
|
const picker = ProductSearchPicker.init('#incoming-picker', {
|
||||||
onAddSelected: function(product, instance) {
|
onAddSelected: function(product, instance) {
|
||||||
if (product) {
|
if (product) {
|
||||||
selectProduct(product);
|
selectProduct(product);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<nav aria-label="breadcrumb" class="mb-2">
|
<nav aria-label="breadcrumb" class="mb-2">
|
||||||
<ol class="breadcrumb breadcrumb-sm mb-0">
|
<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>
|
<li class="breadcrumb-item active">Создать</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>Создать
|
<i class="bi bi-check-lg me-1"></i>Создать
|
||||||
</button>
|
</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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<h4 class="mb-0">
|
<h4 class="mb-0">
|
||||||
<i class="bi bi-file-earmark-plus me-2"></i>Документы поступления
|
<i class="bi bi-file-earmark-plus me-2"></i>Документы поступления
|
||||||
</h4>
|
</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>Создать документ
|
<i class="bi bi-plus-lg me-1"></i>Создать документ
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{% for doc in documents %}
|
{% for doc in documents %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2">
|
<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 }}
|
{{ doc.document_number }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %}
|
{% if doc.created_by %}{{ doc.created_by.username }}{% else %}-{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-end">
|
<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>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
{% if incoming_document %}
|
{% if incoming_document %}
|
||||||
<p class="mb-1">
|
<p class="mb-1">
|
||||||
<strong>Документ оприходования:</strong>
|
<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 }}
|
{{ incoming_document.document_number }}
|
||||||
</a>
|
</a>
|
||||||
<span class="badge {% if incoming_document.status == 'confirmed' %}bg-success{% else %}bg-warning{% endif %} ms-2">
|
<span class="badge {% if incoming_document.status == 'confirmed' %}bg-success{% else %}bg-warning{% endif %} ms-2">
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
# Warehouse
|
# Warehouse
|
||||||
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
||||||
# Incoming
|
|
||||||
IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView,
|
|
||||||
# IncomingBatch
|
# IncomingBatch
|
||||||
IncomingBatchListView, IncomingBatchDetailView,
|
IncomingBatchListView, IncomingBatchDetailView,
|
||||||
# Sale
|
# Sale
|
||||||
@@ -66,13 +64,6 @@ urlpatterns = [
|
|||||||
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
|
path('warehouses/<int:pk>/delete/', WarehouseDeleteView.as_view(), name='warehouse-delete'),
|
||||||
path('warehouses/<int:pk>/set-default/', SetDefaultWarehouseView.as_view(), name='warehouse-set-default'),
|
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 ====================
|
# ==================== INCOMING BATCH ====================
|
||||||
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
|
path('incoming-batches/', IncomingBatchListView.as_view(), name='incoming-batch-list'),
|
||||||
path('incoming-batches/<int:pk>/', IncomingBatchDetailView.as_view(), name='incoming-batch-detail'),
|
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>/confirm/', WriteOffDocumentConfirmView.as_view(), name='writeoff-document-confirm'),
|
||||||
path('writeoff-documents/<int:pk>/cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'),
|
path('writeoff-documents/<int:pk>/cancel/', WriteOffDocumentCancelView.as_view(), name='writeoff-document-cancel'),
|
||||||
|
|
||||||
# ==================== INCOMING DOCUMENT (документы поступления) ====================
|
# ==================== INCOMING (документы поступления) ====================
|
||||||
path('incoming-documents/', IncomingDocumentListView.as_view(), name='incoming-document-list'),
|
path('incoming/', IncomingDocumentListView.as_view(), name='incoming-list'),
|
||||||
path('incoming-documents/create/', IncomingDocumentCreateView.as_view(), name='incoming-document-create'),
|
path('incoming/create/', IncomingDocumentCreateView.as_view(), name='incoming-create'),
|
||||||
path('incoming-documents/<int:pk>/', IncomingDocumentDetailView.as_view(), name='incoming-document-detail'),
|
path('incoming/<int:pk>/', IncomingDocumentDetailView.as_view(), name='incoming-detail'),
|
||||||
path('incoming-documents/<int:pk>/add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-document-add-item'),
|
path('incoming/<int:pk>/add-item/', IncomingDocumentAddItemView.as_view(), name='incoming-add-item'),
|
||||||
path('incoming-documents/<int:pk>/update-item/<int:item_pk>/', IncomingDocumentUpdateItemView.as_view(), name='incoming-document-update-item'),
|
path('incoming/<int:pk>/update-item/<int:item_pk>/', IncomingDocumentUpdateItemView.as_view(), name='incoming-update-item'),
|
||||||
path('incoming-documents/<int:pk>/remove-item/<int:item_pk>/', IncomingDocumentRemoveItemView.as_view(), name='incoming-document-remove-item'),
|
path('incoming/<int:pk>/remove-item/<int:item_pk>/', IncomingDocumentRemoveItemView.as_view(), name='incoming-remove-item'),
|
||||||
path('incoming-documents/<int:pk>/confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-document-confirm'),
|
path('incoming/<int:pk>/confirm/', IncomingDocumentConfirmView.as_view(), name='incoming-confirm'),
|
||||||
path('incoming-documents/<int:pk>/cancel/', IncomingDocumentCancelView.as_view(), name='incoming-document-cancel'),
|
path('incoming/<int:pk>/cancel/', IncomingDocumentCancelView.as_view(), name='incoming-cancel'),
|
||||||
|
|
||||||
# ==================== TRANSFER ====================
|
# ==================== TRANSFER ====================
|
||||||
path('transfers/', TransferListView.as_view(), name='transfer-list'),
|
path('transfers/', TransferListView.as_view(), name='transfer-list'),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from django.shortcuts import render
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
||||||
from .incoming import IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView
|
|
||||||
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
||||||
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
||||||
from .inventory_ops import (
|
from .inventory_ops import (
|
||||||
@@ -58,8 +57,6 @@ __all__ = [
|
|||||||
'inventory_home',
|
'inventory_home',
|
||||||
# Warehouse
|
# Warehouse
|
||||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
||||||
# Incoming
|
|
||||||
'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
|
|
||||||
# IncomingBatch
|
# IncomingBatch
|
||||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
'IncomingBatchListView', 'IncomingBatchDetailView',
|
||||||
# Sale
|
# Sale
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -45,7 +45,7 @@ class IncomingDocumentCreateView(LoginRequiredMixin, CreateView):
|
|||||||
created_by=self.request.user
|
created_by=self.request.user
|
||||||
)
|
)
|
||||||
messages.success(self.request, f'Документ {document.document_number} создан')
|
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):
|
class IncomingDocumentDetailView(LoginRequiredMixin, DetailView):
|
||||||
@@ -110,7 +110,7 @@ class IncomingDocumentAddItemView(LoginRequiredMixin, View):
|
|||||||
for error in errors:
|
for error in errors:
|
||||||
messages.error(request, f'{field}: {error}')
|
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):
|
class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
|
||||||
@@ -146,7 +146,7 @@ class IncomingDocumentUpdateItemView(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||||
messages.error(request, str(e))
|
messages.error(request, str(e))
|
||||||
|
|
||||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
return redirect('inventory:incoming-detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
||||||
@@ -174,7 +174,7 @@ class IncomingDocumentRemoveItemView(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||||
messages.error(request, str(e))
|
messages.error(request, str(e))
|
||||||
|
|
||||||
return redirect('inventory:incoming-document-detail', pk=pk)
|
return redirect('inventory:incoming-detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
||||||
@@ -197,7 +197,7 @@ class IncomingDocumentConfirmView(LoginRequiredMixin, View):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
messages.error(request, str(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):
|
class IncomingDocumentCancelView(LoginRequiredMixin, View):
|
||||||
@@ -213,5 +213,5 @@ class IncomingDocumentCancelView(LoginRequiredMixin, View):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
messages.error(request, str(e))
|
messages.error(request, str(e))
|
||||||
|
|
||||||
return redirect('inventory:incoming-document-list')
|
return redirect('inventory:incoming-list')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user