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

- Добавлено поле 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 =====
@admin.register(IncomingBatch)
class IncomingBatchAdmin(admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'supplier_name', 'items_count', 'created_at')
list_filter = ('warehouse', 'created_at')
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', 'supplier_name', 'notes')
'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
@@ -113,6 +113,20 @@ class IncomingBatchAdmin(admin.ModelAdmin):
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)

View File

@@ -272,6 +272,13 @@ class IncomingForm(forms.Form):
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': 'ООО Поставщик'}),

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,
related_name='incoming_batches', verbose_name="Склад")
document_number = models.CharField(max_length=100, unique=True, db_index=True,
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,
verbose_name="Наименование поставщика")
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
@@ -122,6 +135,7 @@ class IncomingBatch(models.Model):
indexes = [
models.Index(fields=['document_number']),
models.Index(fields=['warehouse']),
models.Index(fields=['receipt_type']),
models.Index(fields=['-created_at']),
]

View File

@@ -11,10 +11,11 @@ from django.db import transaction
from django.utils import timezone
from inventory.models import (
Inventory, InventoryLine, WriteOff, Incoming,
Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch,
StockBatch, Stock
)
from inventory.services.batch_manager import StockBatchManager
from inventory.utils import generate_incoming_document_number
class InventoryProcessor:
@@ -142,21 +143,24 @@ class InventoryProcessor:
inventory.warehouse
)
# Создаем новую партию
batch = StockBatchManager.create_batch(
line.product,
inventory.warehouse,
quantity_surplus,
cost_price
# Генерируем номер документа для поступления
document_number = generate_incoming_document_number()
# Создаем IncomingBatch с типом 'inventory'
incoming_batch = IncomingBatch.objects.create(
warehouse=inventory.warehouse,
document_number=document_number,
receipt_type='inventory',
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
)
# Создаем документ Incoming
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
Incoming.objects.create(
batch=incoming_batch,
product=line.product,
warehouse=inventory.warehouse,
quantity=quantity_surplus,
cost_price=cost_price,
batch=batch,
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: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:inventory-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' %}
{% load inventory_filters %}
{% block inventory_title %}Массовое поступление товара{% endblock %}
{% 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">Поступление товара от поставщика</h4>
<h4 class="mb-0">{% if is_adjustment %}Оприходование товара{% else %}Поступление товара от поставщика{% endif %}</h4>
</div>
<div class="card-body">
<!-- Ошибки общей формы -->
@@ -48,6 +48,7 @@
</div>
</div>
{% if not is_adjustment %}
<div class="mb-3">
<label class="form-label">{{ form.supplier_name.label }}</label>
{{ form.supplier_name }}
@@ -55,6 +56,9 @@
<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>
@@ -120,7 +124,7 @@
<!-- ============== КНОПКИ ============== -->
<div class="d-flex gap-2">
<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>
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена

View File

@@ -21,6 +21,7 @@
<tr>
<th>Товар</th>
<th>Склад</th>
<th>Тип</th>
<th>Количество</th>
<th>Цена закупки</th>
<th>Номер документа</th>
@@ -34,6 +35,11 @@
<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>

View File

@@ -19,6 +19,7 @@
<tr>
<th>Номер документа</th>
<th>Склад</th>
<th>Тип</th>
<th>Поставщик</th>
<th>Товары</th>
<th>Кол-во</th>
@@ -31,6 +32,11 @@
<tr>
<td><strong>{{ batch.document_number }}</strong></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>
<small>

View File

@@ -4,7 +4,7 @@ from .views import (
# Warehouse
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
# Incoming
IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView,
IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView,
# IncomingBatch
IncomingBatchListView, IncomingBatchDetailView,
# Sale
@@ -54,6 +54,7 @@ urlpatterns = [
# ==================== 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'),

View File

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

View File

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