Добавлено разделение типов поступлений на склад

- Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment
- Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации
- Создан IncomingAdjustmentCreateView для оприходования без инвентаризации
- Обновлены формы, шаблоны и админка для поддержки разных типов поступлений
- Добавлена навигация и URL для оприходования
- Тип поступления отображается в списках приходов и партий
This commit is contained in:
2025-12-20 23:47:13 +03:00
parent f1798291e0
commit 78dc9e9801
12 changed files with 238 additions and 20 deletions

View File

@@ -94,13 +94,13 @@ class StockBatchAdmin(admin.ModelAdmin):
# ===== INCOMING BATCH ===== # ===== INCOMING BATCH =====
@admin.register(IncomingBatch) @admin.register(IncomingBatch)
class IncomingBatchAdmin(admin.ModelAdmin): class IncomingBatchAdmin(admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'supplier_name', 'items_count', 'created_at') list_display = ('document_number', 'warehouse', 'receipt_type_display', 'supplier_name', 'items_count', 'created_at')
list_filter = ('warehouse', 'created_at') list_filter = ('warehouse', 'receipt_type', 'created_at')
search_fields = ('document_number', 'supplier_name') search_fields = ('document_number', 'supplier_name')
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
fieldsets = ( fieldsets = (
('Партия поступления', { ('Партия поступления', {
'fields': ('document_number', 'warehouse', 'supplier_name', 'notes') 'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes')
}), }),
('Даты', { ('Даты', {
'fields': ('created_at', 'updated_at'), 'fields': ('created_at', 'updated_at'),
@@ -113,6 +113,20 @@ class IncomingBatchAdmin(admin.ModelAdmin):
return obj.items.count() return obj.items.count()
items_count.short_description = 'Товаров' 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 ===== # ===== INCOMING =====
@admin.register(Incoming) @admin.register(Incoming)

View File

@@ -272,6 +272,13 @@ class IncomingForm(forms.Form):
required=False required=False
) )
receipt_type = forms.CharField(
max_length=20,
widget=forms.HiddenInput(),
initial='supplier',
required=False
)
supplier_name = forms.CharField( supplier_name = forms.CharField(
max_length=200, max_length=200,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0.10 on 2025-12-20 20:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0012_change_sold_order_item_to_fk'),
]
operations = [
migrations.AddField(
model_name='incomingbatch',
name='receipt_type',
field=models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления'),
),
migrations.AddIndex(
model_name='incomingbatch',
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'),
),
]

View File

@@ -105,10 +105,23 @@ class IncomingBatch(models.Model):
Партия поступления товара (один номер документа = одна партия). Партия поступления товара (один номер документа = одна партия).
Содержит один номер документа и может включать несколько товаров. Содержит один номер документа и может включать несколько товаров.
""" """
RECEIPT_TYPE_CHOICES = [
('supplier', 'Поступление от поставщика'),
('inventory', 'Оприходование при инвентаризации'),
('adjustment', 'Оприходование без инвентаризации'),
]
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
related_name='incoming_batches', verbose_name="Склад") related_name='incoming_batches', verbose_name="Склад")
document_number = models.CharField(max_length=100, unique=True, db_index=True, document_number = models.CharField(max_length=100, unique=True, db_index=True,
verbose_name="Номер документа") verbose_name="Номер документа")
receipt_type = models.CharField(
max_length=20,
choices=RECEIPT_TYPE_CHOICES,
default='supplier',
db_index=True,
verbose_name="Тип поступления"
)
supplier_name = models.CharField(max_length=200, blank=True, null=True, supplier_name = models.CharField(max_length=200, blank=True, null=True,
verbose_name="Наименование поставщика") verbose_name="Наименование поставщика")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания") notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
@@ -122,6 +135,7 @@ class IncomingBatch(models.Model):
indexes = [ indexes = [
models.Index(fields=['document_number']), models.Index(fields=['document_number']),
models.Index(fields=['warehouse']), models.Index(fields=['warehouse']),
models.Index(fields=['receipt_type']),
models.Index(fields=['-created_at']), models.Index(fields=['-created_at']),
] ]

View File

@@ -11,10 +11,11 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from inventory.models import ( from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming, Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch,
StockBatch, Stock StockBatch, Stock
) )
from inventory.services.batch_manager import StockBatchManager from inventory.services.batch_manager import StockBatchManager
from inventory.utils import generate_incoming_document_number
class InventoryProcessor: class InventoryProcessor:
@@ -142,21 +143,24 @@ class InventoryProcessor:
inventory.warehouse inventory.warehouse
) )
# Создаем новую партию # Генерируем номер документа для поступления
batch = StockBatchManager.create_batch( document_number = generate_incoming_document_number()
line.product,
inventory.warehouse, # Создаем IncomingBatch с типом 'inventory'
quantity_surplus, incoming_batch = IncomingBatch.objects.create(
cost_price warehouse=inventory.warehouse,
document_number=document_number,
receipt_type='inventory',
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
) )
# Создаем документ Incoming # Создаем документ Incoming
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
Incoming.objects.create( Incoming.objects.create(
batch=incoming_batch,
product=line.product, product=line.product,
warehouse=inventory.warehouse,
quantity=quantity_surplus, quantity=quantity_surplus,
cost_price=cost_price, cost_price=cost_price,
batch=batch,
notes=f'Инвентаризация {inventory.id}, строка {line.id}' notes=f'Инвентаризация {inventory.id}, строка {line.id}'
) )

View File

@@ -25,6 +25,7 @@
<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-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>

View File

@@ -1,12 +1,12 @@
{% extends 'inventory/base_inventory_minimal.html' %} {% extends 'inventory/base_inventory_minimal.html' %}
{% load inventory_filters %} {% load inventory_filters %}
{% block inventory_title %}Массовое поступление товара{% endblock %} {% block inventory_title %}{% if is_adjustment %}Оприходование товара{% else %}Массовое поступление товара{% endif %}{% endblock %}
{% block breadcrumb_current %}Приходы{% endblock %} {% block breadcrumb_current %}Приходы{% endblock %}
{% block inventory_content %} {% block inventory_content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h4 class="mb-0">Поступление товара от поставщика</h4> <h4 class="mb-0">{% if is_adjustment %}Оприходование товара{% else %}Поступление товара от поставщика{% endif %}</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Ошибки общей формы --> <!-- Ошибки общей формы -->
@@ -48,6 +48,7 @@
</div> </div>
</div> </div>
{% if not is_adjustment %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ form.supplier_name.label }}</label> <label class="form-label">{{ form.supplier_name.label }}</label>
{{ form.supplier_name }} {{ form.supplier_name }}
@@ -55,6 +56,9 @@
<div class="text-danger small">{{ form.supplier_name.errors.0 }}</div> <div class="text-danger small">{{ form.supplier_name.errors.0 }}</div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{{ form.receipt_type }}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ form.notes.label }}</label> <label class="form-label">{{ form.notes.label }}</label>
@@ -120,7 +124,7 @@
<!-- ============== КНОПКИ ============== --> <!-- ============== КНОПКИ ============== -->
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" id="submitBtn"> <button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-check-circle"></i> Создать поступление <i class="bi bi-check-circle"></i> {% if is_adjustment %}Создать оприходование{% else %}Создать поступление{% endif %}
</button> </button>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary"> <a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена <i class="bi bi-x-circle"></i> Отмена

View File

@@ -21,6 +21,7 @@
<tr> <tr>
<th>Товар</th> <th>Товар</th>
<th>Склад</th> <th>Склад</th>
<th>Тип</th>
<th>Количество</th> <th>Количество</th>
<th>Цена закупки</th> <th>Цена закупки</th>
<th>Номер документа</th> <th>Номер документа</th>
@@ -34,6 +35,11 @@
<tr> <tr>
<td><strong>{{ incoming.product.name }}</strong></td> <td><strong>{{ incoming.product.name }}</strong></td>
<td>{{ incoming.batch.warehouse.name }}</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.quantity|smart_quantity }} шт</td>
<td>{{ incoming.cost_price }} руб.</td> <td>{{ incoming.cost_price }} руб.</td>
<td> <td>

View File

@@ -19,6 +19,7 @@
<tr> <tr>
<th>Номер документа</th> <th>Номер документа</th>
<th>Склад</th> <th>Склад</th>
<th>Тип</th>
<th>Поставщик</th> <th>Поставщик</th>
<th>Товары</th> <th>Товары</th>
<th>Кол-во</th> <th>Кол-во</th>
@@ -31,6 +32,11 @@
<tr> <tr>
<td><strong>{{ batch.document_number }}</strong></td> <td><strong>{{ batch.document_number }}</strong></td>
<td>{{ batch.warehouse.name }}</td> <td>{{ batch.warehouse.name }}</td>
<td>
<span class="badge bg-{% if batch.receipt_type == 'supplier' %}primary{% elif batch.receipt_type == 'inventory' %}info{% else %}success{% endif %}">
{{ batch.get_receipt_type_display }}
</span>
</td>
<td>{{ batch.supplier_name|default:"—" }}</td> <td>{{ batch.supplier_name|default:"—" }}</td>
<td> <td>
<small> <small>

View File

@@ -4,7 +4,7 @@ from .views import (
# Warehouse # Warehouse
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView, WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
# Incoming # Incoming
IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView, IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView,
# IncomingBatch # IncomingBatch
IncomingBatchListView, IncomingBatchDetailView, IncomingBatchListView, IncomingBatchDetailView,
# Sale # Sale
@@ -54,6 +54,7 @@ urlpatterns = [
# ==================== INCOMING ==================== # ==================== INCOMING ====================
path('incoming/', IncomingListView.as_view(), name='incoming-list'), path('incoming/', IncomingListView.as_view(), name='incoming-list'),
path('incoming/create/', IncomingCreateView.as_view(), name='incoming-create'), 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>/edit/', IncomingUpdateView.as_view(), name='incoming-update'),
path('incoming/<int:pk>/delete/', IncomingDeleteView.as_view(), name='incoming-delete'), path('incoming/<int:pk>/delete/', IncomingDeleteView.as_view(), name='incoming-delete'),

View File

@@ -19,7 +19,7 @@ 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, IncomingUpdateView, IncomingDeleteView 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 (
@@ -48,7 +48,7 @@ __all__ = [
# Warehouse # Warehouse
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView', 'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
# Incoming # Incoming
'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView', 'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
# IncomingBatch # IncomingBatch
'IncomingBatchListView', 'IncomingBatchDetailView', 'IncomingBatchListView', 'IncomingBatchDetailView',
# Sale # Sale

View File

@@ -119,6 +119,7 @@ class IncomingCreateView(LoginRequiredMixin, View):
# Оставляем пустой document_number как есть - модель будет генерировать при сохранении # Оставляем пустой document_number как есть - модель будет генерировать при сохранении
document_number = form.cleaned_data.get('document_number', '').strip() or None 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', '') supplier_name = form.cleaned_data.get('supplier_name', '')
header_notes = form.cleaned_data.get('notes', '') header_notes = form.cleaned_data.get('notes', '')
@@ -148,7 +149,8 @@ class IncomingCreateView(LoginRequiredMixin, View):
batch = IncomingBatch.objects.create( batch = IncomingBatch.objects.create(
warehouse=warehouse, warehouse=warehouse,
document_number=document_number, document_number=document_number,
supplier_name=supplier_name, receipt_type=receipt_type,
supplier_name=supplier_name if receipt_type == 'supplier' else '',
notes=header_notes notes=header_notes
) )
file_logger.info(f" ✓ Created batch: {document_number}") file_logger.info(f" ✓ Created batch: {document_number}")
@@ -208,7 +210,8 @@ class IncomingCreateView(LoginRequiredMixin, View):
} }
return render(request, self.template_name, context) return render(request, self.template_name, context)
def _parse_products_from_post(self, post_data): @staticmethod
def _parse_products_from_post(post_data):
""" """
Парсит данные товаров из POST данных. Парсит данные товаров из POST данных.
Ожидается formato: Ожидается formato:
@@ -235,3 +238,139 @@ class IncomingCreateView(LoginRequiredMixin, View):
pass pass
return products_data 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)